From cec85cf2e0ef37cd011870f85e467c865914fae0 Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Mon, 26 Oct 2026 17:21:26 +0100 Subject: [PATCH 1/3] stop session stack pollution on repeated teardown starts --- lib/recorder.js | 12 +++++++++++- test/unit/recorder_test.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/recorder.js b/lib/recorder.js index a83e0baa1..fe09b2808 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -125,6 +125,15 @@ export default { * @inner */ start(name) { + if (sessionId === name) { + // re-starting the same session abandons the current (broken) chain + // without polluting the session stack + debug(`${currentQueue()}Session <${name}> already started, abandoning its chain`) + tasks.push('--->') + this.running = true + promise = Promise.resolve() + return + } if (sessionId) { debug(`${currentQueue()}Session already started as ${sessionId}, nesting session ${name}`) // Push current session to stack instead of restoring it @@ -151,7 +160,8 @@ export default { debug(`${currentQueue()}Finalize <${name}> session`) this.running = false this.catch(errFn) - promise = promise.then(() => oldPromises.pop()) + const restored = oldPromises.pop() + if (restored && restored !== promise) promise = promise.then(() => restored) // Restore parent session from stack if available if (sessionStack.length > 0) { diff --git a/test/unit/recorder_test.js b/test/unit/recorder_test.js index 04096ee6d..872326ef0 100644 --- a/test/unit/recorder_test.js +++ b/test/unit/recorder_test.js @@ -28,6 +28,38 @@ describe('Recorder', () => { recorder.add(() => (order += 'b')) return recorder.promise().then(() => expect(order).is.equal('acdb')) }) + + it('re-starting the same session name does not pollute the session stack', () => { + let order = '' + recorder.add(() => (order += 'a')) + recorder.add(() => { + recorder.session.start('teardown') + recorder.add(() => (order += 'c')) + recorder.session.start('teardown') // leaked teardown re-start: no sessionStack/oldPromises push + recorder.add(() => (order += 'd')) + }) + recorder.add(() => recorder.session.restore('teardown')) + recorder.add(() => (order += 'b')) + // steps already attached to the live chain still run; a broken one would be abandoned + return recorder.promise().then(() => expect(order).is.equal('acdb')) + }) + + it('does not chain a promise onto itself when restoring an unbalanced stack', async () => { + const unhandled = [] + const onUnhandled = e => unhandled.push(e) + process.on('unhandledRejection', onUnhandled) + try { + recorder.session.start('s1') + recorder.add(() => 'step') + recorder.session.restore('s1') + recorder.session.restore('extra') // nothing left to pop, must not create a cycle + await recorder.promise() + await new Promise(r => setTimeout(r, 20)) + expect(unhandled).to.have.length(0) + } finally { + process.removeListener('unhandledRejection', onUnhandled) + } + }) }) describe('#add', () => { From 4e17debdda77302a24abaea4c71d11f5ca40307a Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Tue, 27 Oct 2026 13:27:35 +0100 Subject: [PATCH 2/3] drain the failed recorder chain before retrying a hook --- lib/mocha/asyncWrapper.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/mocha/asyncWrapper.js b/lib/mocha/asyncWrapper.js index 1457a5ce1..33ee8ba3e 100644 --- a/lib/mocha/asyncWrapper.js +++ b/lib/mocha/asyncWrapper.js @@ -165,6 +165,9 @@ export function injected(fn, suite, hookName) { retry(err) } finally { if (number < retries) { + // drain the failed chain so in-flight steps (sessions, listeners) + // finish before the next attempt starts queueing new ones + await recorder.promise().catch(() => {}) recorder.stop() recorder.start() } From 88c38a3fa308ab1c1b226820e791c8209e35a621 Mon Sep 17 00:00:00 2001 From: Denys Kuchma Date: Fri, 6 Nov 2026 15:54:31 +0100 Subject: [PATCH 3/3] isolate failed hook retries from session and teardown state --- lib/mocha/asyncWrapper.js | 14 +++++++++++--- lib/plugin/auth.js | 3 +++ lib/recorder.js | 10 +++++++++- test/unit/recorder_test.js | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/lib/mocha/asyncWrapper.js b/lib/mocha/asyncWrapper.js index 33ee8ba3e..bc11f2495 100644 --- a/lib/mocha/asyncWrapper.js +++ b/lib/mocha/asyncWrapper.js @@ -54,7 +54,10 @@ export function test(test) { test.fn = function (done) { const doneFn = makeDoneCallableOnce(done) + let recoveryStarted = false recorder.errHandler(err => { + if (recoveryStarted) return + recoveryStarted = true recorder.session.start('teardown') recorder.cleanAsyncErr() if (test.throws) { @@ -114,7 +117,13 @@ export function test(test) { export function injected(fn, suite, hookName) { return function (done) { const doneFn = makeDoneCallableOnce(done) + const opts = suite.opts || {} + const retries = opts[`retry${ucfirst(hookName)}`] || 0 + let retryPending = retries > 0 + let recoveryStarted = false const errHandler = err => { + if (retryPending || recoveryStarted) return + recoveryStarted = true recorder.session.start('teardown') recorder.cleanAsyncErr() if (['before', 'beforeSuite'].includes(hookName)) { @@ -149,9 +158,6 @@ export function injected(fn, suite, hookName) { }) } - const opts = suite.opts || {} - const retries = opts[`retry${ucfirst(hookName)}`] || 0 - const currentTest = hookName === 'before' || hookName === 'after' ? suite?.ctx?.currentTest : null promiseRetry( @@ -170,6 +176,8 @@ export function injected(fn, suite, hookName) { await recorder.promise().catch(() => {}) recorder.stop() recorder.start() + } else { + retryPending = false } } }, diff --git a/lib/plugin/auth.js b/lib/plugin/auth.js index 7e89c0635..e487017b5 100644 --- a/lib/plugin/auth.js +++ b/lib/plugin/auth.js @@ -303,6 +303,7 @@ export default function (config) { section.start() + const queueId = recorder.getQueueId() const cookies = store[`${name}_session`] const shouldAwait = isAsyncFunction(userSession.login) || isAsyncFunction(userSession.restore) || isAsyncFunction(userSession.check) @@ -338,6 +339,7 @@ export default function (config) { } section.end() recorder.session.catch(err => { + if (recorder.getQueueId() !== queueId) return output.debug(`Failed auto login for ${name} due to ${err}`) output.debug('Logging in again') recorder.session.start('auto login') @@ -355,6 +357,7 @@ export default function (config) { }) }) recorder.add(() => { + if (recorder.getQueueId() !== queueId) return recorder.session.restore('check login') }) diff --git a/lib/recorder.js b/lib/recorder.js index fe09b2808..38893a1fd 100644 --- a/lib/recorder.js +++ b/lib/recorder.js @@ -89,6 +89,8 @@ export default { */ reset() { if (promise && running) this.catch() + else swallow(promise) + oldPromises.forEach(swallow) queueId++ sessionId = null sessionStack = [] // Clear the session stack @@ -131,6 +133,7 @@ export default { debug(`${currentQueue()}Session <${name}> already started, abandoning its chain`) tasks.push('--->') this.running = true + swallow(promise) promise = Promise.resolve() return } @@ -145,7 +148,7 @@ export default { } debug(`${currentQueue()}Starting <${name}> session`) tasks.push('--->') - oldPromises.push(promise) + oldPromises.push(swallow(promise)) this.running = true sessionId = name promise = Promise.resolve() @@ -436,6 +439,11 @@ export default { }, } +function swallow(p) { + if (p) p.catch(() => {}) + return p +} + function getTimeoutPromise(timeoutMs, taskName) { let timer if (timeoutMs) debug(`Timing out in ${timeoutMs}ms`) diff --git a/test/unit/recorder_test.js b/test/unit/recorder_test.js index 872326ef0..87c492250 100644 --- a/test/unit/recorder_test.js +++ b/test/unit/recorder_test.js @@ -60,6 +60,44 @@ describe('Recorder', () => { process.removeListener('unhandledRejection', onUnhandled) } }) + + it('does not report unhandled rejections for chains abandoned on session re-start', async () => { + const unhandled = [] + const onUnhandled = e => unhandled.push(e) + process.on('unhandledRejection', onUnhandled) + try { + recorder.add('failing step', () => { + throw new Error('boom') + }) + recorder.session.start('teardown') + recorder.session.start('teardown') // abandons the chain holding the failing step + recorder.add(() => 'recovered') + await recorder.promise() + await new Promise(r => setTimeout(r, 20)) + expect(unhandled).to.have.length(0) + } finally { + process.removeListener('unhandledRejection', onUnhandled) + } + }) + + it('does not report unhandled rejections for chains discarded by reset', async () => { + const unhandled = [] + const onUnhandled = e => unhandled.push(e) + process.on('unhandledRejection', onUnhandled) + try { + recorder.add('failing step', () => { + throw new Error('boom') + }) + recorder.stop() + recorder.start() + recorder.add(() => 'fresh') + await recorder.promise() + await new Promise(r => setTimeout(r, 20)) + expect(unhandled).to.have.length(0) + } finally { + process.removeListener('unhandledRejection', onUnhandled) + } + }) }) describe('#add', () => {