Skip to content

cy.session restore sometimes fails with a timeout #33419

@iomedico-beyer

Description

@iomedico-beyer

Current behavior

When running hours of tests, in some environments (e.g. our Gitlab CI) cy.session restore sometimes fails with:

CypressError: `cy.then()` timed out after waiting `20000ms`.

Your callback function returned a promise that never resolved.

The callback function was:

async () => {
          setSessionLogStatus(_utils__WEBPACK_IMPORTED_MODULE_6__.statusMap.inProgress(_utils__WEBPACK_IMPORTED_MODULE_6__.SESSION_STEPS.restore));
          await (0,_utils__WEBPACK_IMPORTED_MODULE_6__.navigateAboutBlank)();
          await sessions.clearCurrentSessionData();
          return restoreSession(existingSession);
        }

https://on.cypress.io/then
    at <unknown> (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:122830:75)
    at tryCatcher (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1777:23)
    at <unknown> (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:4156:41)
    at tryCatcher (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1777:23)
    at Promise._settlePromiseFromHandler (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1489:31)
    at Promise._settlePromise (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1546:18)
    at Promise._settlePromise0 (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1591:10)
    at Promise._settlePromises (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1667:18)
    at _drainQueueStep (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2377:12)
    at _drainQueue (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2370:9)
    at Async._drainQueues (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2386:5)
    at Async.drainQueues (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2256:14)

Desired behavior

No response

Test code to reproduce

Please run this in Gitlab CI:

/*
 * This test is to replicate a problem seen in Gitlab CI.
 * https://github.com/cypress-io/cypress/issues/33419
 */
Cypress._.times(10, (o) => {
   it("tries to trigger the session restore bug, o == " + o, () => {
      Cypress._.times(100, () => {
         cy.session("foo", () => {
            cy.visit("https://example.com/")
         },
         {
            /*
             * a validate function seems to be
             * needed to replicate the problem
             */
            validate() {
               cy.log("validate")
            },
         })
      })
   })
})

Cypress Version

15.11.0

Debug Logs

[13:37:37] $ yarn cypress run --browser chrome --headless --spec cypress/e2e/auto/$TEST_SUITE
[13:37:38]  Verifying Cypress can run /root/.cache/Cypress/15.11.0/Cypress [started]
[13:37:40]  Verifying Cypress can run /root/.cache/Cypress/15.11.0/Cypress [COMPLETED]
DevTools listening on ws://127.0.0.1:40217/devtools/browser/83c5ed40-9b9f-4a9d-b56a-022b75f88cbd
<<<CYPRESS.STDERR.START>>>npm warn exec The following package was not found and will be installed: tsx@4.21.0
====================================================================================================
  (Run Starting)
  ┌────────────────────────────────────────────────────────────────────────────────────────────────┐
  │ Cypress:        15.11.0                                                                        │
  │ Browser:        Chrome 144 (headless)                                                          │
  │ Node Version:   v24.13.0 (/usr/local/bin/node)                                                 │
  │ Specs:          1 found (cypress-bug.cy.ts)                                                    │
  │ Searched:       cypress/e2e/cypress-bug.cy.ts                                                  │
  └────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
                                                                                                    
  Running:  cypress-bug.cy.ts                                                     (1 of 1)
  ✓ tries to trigger the session restore bug + 0 (2077804ms)
  2 passing (39m)
  1 failing
     CypressError: `cy.then()` timed out after waiting `20000ms`.
Your callback function returned a promise that never resolved.
The callback function was:
async () => {
          setSessionLogStatus(_utils__WEBPACK_IMPORTED_MODULE_6__.statusMap.inProgress(step));
          await (0,_utils__WEBPACK_IMPORTED_MODULE_6__.navigateAboutBlank)();
          await sessions.clearCurrentSessionData();
          return cy.whenStable(() => createSession(existingSession, step));
        }
https://on.cypress.io/then
Because this error occurred during a `before all` hook we are skipping all of the remaining tests.
      at <unknown> (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:122830:75)
      at tryCatcher (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1777:23)
      at <unknown> (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:4156:41)
      at tryCatcher (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1777:23)
      at Promise._settlePromiseFromHandler (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1489:31)
      at Promise._settlePromise (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1546:18)
      at Promise._settlePromise0 (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1591:10)
      at Promise._settlePromises (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:1667:18)
      at _drainQueueStep (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2377:12)
      at _drainQueue (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2370:9)
      at Async._drainQueues (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2386:5)
      at <unknown> (http://edc6-autotest.os.iomedico.local/__cypress/runner/cypress_runner.js:2256:14)

Workaround

I have this workaround in my commands.ts file where I have custom commands etc.
Another good place might be cypress/support/e2e.ts, I guess.
And if you're not using TypeScript, you'll obviously need to remove the types.

/**
 * Workaround for a known Cypress bug where the internal navigateAboutBlank()
 * during cy.session restore hangs forever because the "window:load" event
 * never fires after navigating to about:blank.
 * https://github.com/cypress-io/cypress/issues/29496
 * https://github.com/cypress-io/cypress/issues/31988
 * https://github.com/cypress-io/cypress/issues/33419
 *
 * Root cause: navigateAboutBlank() does:
 *   1. cy.once('window:load', resolve)
 *   2. Cypress.action('cy:visit:blank', ...)
 * If about:blank loads before the listener receives the event (race condition),
 * the Promise never resolves and the test times out after 20s.
 *
 * Fix strategy:
 * - We intercept cy.once('window:load', ...) and check cy.state('current').
 * - navigateAboutBlank() is called from an async Promise callback, so there is
 *   no active Cypress command (current is not 'visit'/'reload'/'go').
 * - cy.visit/cy.reload/cy.go always have their command name as cy.state('current')
 *   when they register the window:load listener.
 * - If the caller is navigateAboutBlank():
 *     Case 1: AUT already on about:blank → call the listener (resolve()) directly,
 *             then install a stability guard that restores isStable(true) if
 *             visitBlankPage's subsequent src="about:blank" causes isStable(false)
 *             without a matching load event.
 *     Case 2: AUT on a real page → pass through; the race condition does not
 *             apply here because about:blank hasn't loaded yet.
 * - If the caller is cy.visit/cy.reload/cy.go → pass through unchanged.
 */
function getAutHref(): string | null {
   try {
      const aut = window.top?.document.querySelector("iframe.aut-iframe") as HTMLIFrameElement | null
      return aut?.contentWindow?.location.href ?? ""
   } catch {
      return null // SecurityError: cross-origin
   }
}
const originalCyOnce = (cy as any).once.bind(cy)
;(cy as any).once = function(event: string, listener: (...args: any[]) => void, ...rest: any[]) {
   if (event === "window:load") {
      const href = getAutHref()
      const isAboutBlank = href === "" || href === "about:blank"
      /*
       * navigateAboutBlank() is called from an async Promise callback, outside
       * of the Cypress command queue. cy.state('current') is therefore null or
       * the previously completed command — not 'visit'/'reload'/'go'.
       * cy.visit/cy.reload/cy.go always have cy.state('current')?.get('name')
       * equal to their command name when they register cy.once('window:load').
       */
      const currentCmd: string | undefined = (cy as any).state?.("current")?.get?.("name")
      const isFromNavigateAboutBlank = currentCmd !== "visit" && currentCmd !== "reload" && currentCmd !== "go"
      Cypress.log({
         name: "cy.once workaround",
         message: `href="${
            href ?? "(cross-origin)"
         }", isAboutBlank=${isAboutBlank}, currentCmd=${currentCmd}, isFromNavigateAboutBlank=${isFromNavigateAboutBlank}`,
      })
      if (isAboutBlank && isFromNavigateAboutBlank) {
         /*
          * Case 1: The frame is already on about:blank when the listener is registered.
          *
          * Root cause: after this cy.once() call, navigateAboutBlank() fires
          * Cypress.action('cy:visit:blank'), which calls visitBlankPage() in the
          * runner UI. visitBlankPage() sets $iframe[0].src = "about:blank" and
          * waits for a "load" event. But setting src to the same value does NOT
          * fire a new load event → visitBlankPage()'s Promise never resolves.
          *
          * Worse: the src="about:blank" attempt fires beforeunload on the
          * contentWindow → cy.isStable(false). No matching load follows, so
          * isStable stays false. The CommandQueue calls whenStable() before each
          * command → hangs → outer cy.then() times out after 20s.
          *
          * Fix: Call the listener (= resolve()) directly to unblock
          * navigateAboutBlank(). Then install a one-shot stability guard that
          * restores isStable(true) if visitBlankPage's side effect sets it to
          * false without a subsequent load event.
          */
         const aut = window.top?.document.querySelector("iframe.aut-iframe") as HTMLIFrameElement | null
         listener(aut?.contentWindow)
         Cypress.log({ name: "cy.once workaround", message: "Case 1: called listener directly (already on about:blank)" })

         // Guard: visitBlankPage() will set src="about:blank" which may fire
         // beforeunload → isStable(false) without a subsequent load → isStable
         // stuck at false. If that happens, force stability back after a short delay.
         const restoreStability = () => {
            setTimeout(() => {
               if (!(cy as any).state?.("isStable")) {
                  ;(cy as any).isStable?.(true, "workaround")
                  Cypress.log({ name: "cy.once workaround", message: "Case 1: forced isStable(true) after visitBlankPage side effect" })
               }
            }, 200)
         }
         Cypress.once("stability:changed" as any, (stable: boolean) => {
            if (!stable) restoreStability()
         })

         return this
      }
   }
   return originalCyOnce(event, listener, ...rest)
}```

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions