Repository: vercel/next.js
Pull request: Fix reentrancy of unhandledRejection filtering

Node's internal implementation of process.removeAllListeners calls process.removeListener internally. The simple patching method originally tried does not account for this which can lead to listeners not being removed from the actual process event emitter.

To fix this I modeled the patch as explicitly not allowing sync reentrancy. You can never call a patched function from inside the execution of another because we bypass it by setting a flag during patch execution.

Because of this I also removed the calls to original*() methods while installing and uninstalling the patch. These can be done using the regular process methods before and after installation/uninstallation respectively. This was done to avoid needing to manually scope the bypass which would have been fine but is a bit more convoluted and its easier to just use the true underlying methods in the right temporal scope

I also removed an unecessary rejectionHandled handler because we not longer use catch on the underlying promise and thus do not create  any new handled rejections.

I also added unit tests

Changed files:
filename: packages/next/src/server/node-environment-extensions/unhandled-rejection.test.ts
patch: @@ -0,0 +1,1015 @@
+/**
+ * Testing unhandled rejections in Jest is quite tricky because by default Jest will fail the test
+ * if there are any unhandled rejections. This is a unit test and we want to test the node patch
+ * with as little manipulation of the runtime as possible. The strategy we take is to run a special
+ * function in each test inside a Worker thread. This allows us to have an isolated node environment
+ * that won't interfere with anything going on inside the process running Jest. We support a native TS authoring
+ * experience by passing the code for the Worker as a string gathered from toString-ing the worker test function.
+ *
+ * This is convenient for typescript and linting but it isn't perfect because it might trick you into thinking
+ * you can share state from inside and outside the work function which you cannot. Instead you must report serializable
+ * values back to the main thread using `reportResult`. Another place to watch out for is that requiring code in the
+ * worker requires targeting next/dist files (javascript only) and you must target from the package root down.
+ * Environment variables are not carried over to the worker thread so if you you want to configure them it
+ * must be done inside the worker function implementation.
+ *
+ * I'm largely happy with this setup because you can mostly ignore the fact that there are two worlds
+ * but if you make changes consider the context these tests are running in otherwise you might get unexpected results.
+ *
+ * @jest-environment node
+ */
+
+/* eslint-disable @next/internal/typechecked-require */
+
+type ReportableResult =
+  | CountReport
+  | UHRReport
+  | ErrorReport
+  | OutputReport
+  | ErrorLogReport
+  | SerializableDataReport
+type ErrorReport = { type: 'error'; message: string }
+type OutputReport = { type: 'output'; message: string }
+type CountReport = {
+  type: 'count'
+  count: number
+}
+type UHRReport = {
+  type: 'uhr'
+  reason: string
+}
+type ErrorLogReport = {
+  type: 'error-log'
+  message: string
+}
+type SerializableDataReport = {
+  type: 'serialized'
+  key: string
+  data: string
+}
+
+declare global {
+  function reportResult(result: ReportableResult): void
+}
+
+import type { WorkUnitStore } from '../app-render/work-unit-async-storage.external'
+
+import { Worker } from 'node:worker_threads'
+
+type WorkerResult = {
+  exitCode: number
+  stderr: string
+  uhr: Array<UHRReport>
+  count: Array<CountReport>
+  errorLog: Array<ErrorLogReport>
+  data: Record<string, unknown>
+  messages: Array<ReportableResult>
+}
+
+export function runWorkerCode(fn: Function): Promise<WorkerResult> {
+  return new Promise((resolve, reject) => {
+    const script = `
+      const { parentPort } = require('node:worker_threads');
+      (async () => {
+        const { AsyncLocalStorage } = require('node:async_hooks');
+        // We need to put this on the global because Next.js does not import it
+        // from node directly to be compatible with edge runtimes.
+        globalThis.AsyncLocalStorage = AsyncLocalStorage;
+
+        const { parentPort } = require('node:worker_threads');
+        global.reportResult = (value) => {
+          parentPort?.postMessage(value);
+        };
+
+        const fn = (${fn.toString()});
+        try {
+          const out = await fn();
+          await new Promise(r => setImmediate(r));
+          reportResult({ type: 'result', out });
+        } catch (e) {
+          reportResult({ type: 'error', message: String(e && e.message || e) });
+        }
+      })();
+    `
+
+    const w = new Worker(script, {
+      eval: true,
+      workerData: null,
+      argv: [],
+      execArgv: [],
+      stderr: true,
+      stdout: false,
+    })
+
+    const messages: Array<ReportableResult> = []
+    const uhr: Array<UHRReport> = []
+    const count: Array<CountReport> = []
+    const errorLog: Array<ErrorLogReport> = []
+    const data = {} as Record<string, unknown>
+    let stderr = ''
+
+    w.on('message', (m) => {
+      messages.push(m)
+      switch (m.type) {
+        case 'uhr':
+          uhr.push(m.reason)
+          break
+        case 'count':
+          count.push(m.count)
+          break
+        case 'error-log':
+          errorLog.push(m.message)
+          break
+        case 'serialized':
+          data[m.key] = JSON.parse(m.data)
+          break
+        default:
+          break
+      }
+    })
+    w.on('error', (err) => console.error('Worker error', err))
+    w.on('error', reject)
+    w.stderr?.on('data', (b) => (stderr += String(b)))
+    w.on('exit', (code) =>
+      resolve({
+        exitCode: code ?? -1,
+        uhr,
+        count,
+        errorLog,
+        data,
+        messages,
+        stderr,
+      })
+    )
+  })
+}
+
+describe('unhandled-rejection filter', () => {
+  describe('environment variable configuration', () => {
+    it('should not install filter by default', async () => {
+      async function testForWorker() {
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        reportResult({
+          type: 'count',
+          count: process.listeners('unhandledRejection').length,
+        })
+      }
+
+      const { messages, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(messages).toEqual(
+        expect.arrayContaining([expect.objectContaining({ count: 0 })])
+      )
+    })
+
+    it('should not install filter when disabled', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'disabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        reportResult({
+          type: 'count',
+          count: process.listeners('unhandledRejection').length,
+        })
+      }
+
+      const { messages, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(messages).toEqual(
+        expect.arrayContaining([expect.objectContaining({ count: 0 })])
+      )
+    })
+
+    it('should install filter rejections when environment variable is enabled', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        reportResult({
+          type: 'count',
+          count: process.listeners('unhandledRejection').length,
+        })
+      }
+
+      const { messages, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(messages).toEqual(
+        expect.arrayContaining([expect.objectContaining({ count: 1 })])
+      )
+    })
+
+    it('should install filter rejections when environment variable is enabled in debug mode', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'debug'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        reportResult({
+          type: 'count',
+          count: process.listeners('unhandledRejection').length,
+        })
+      }
+
+      const { messages, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(messages).toEqual(
+        expect.arrayContaining([expect.objectContaining({ count: 1 })])
+      )
+    })
+  })
+
+  describe('filtering functionality', () => {
+    it('should suppress rejections from aborted prerender contexts', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = '1'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside store + before')
+        workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            Promise.reject('immediate abort + sync')
+            await 1
+            Promise.reject('immediate abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('immediate abort + task')
+          }
+        )
+        const delayedAbortStore = {
+          type: 'prerender',
+          renderSignal: { aborted: false },
+        }
+        workUnitAsyncStorage.run(
+          delayedAbortStore as WorkUnitStore,
+          async () => {
+            Promise.reject('before abort + sync')
+            await 1
+            Promise.reject('before abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('before abort + task')
+            await new Promise((r) => setImmediate(r))
+            // We mutate this after a task b/c in Next.js this is always done right at the beginning
+            // of a task and any promises rejecting in prior tasks would have already observed their
+            // rejections as unhandled without the aborted signal
+            delayedAbortStore.renderSignal.aborted = true
+            Promise.reject('after abort + sync')
+            await 1
+            Promise.reject('after abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('delayed abort + task')
+          }
+        )
+        Promise.reject('outside store + after')
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(
+        expect.arrayContaining([
+          'outside store + before',
+          'outside store + after',
+          'before abort + sync',
+          'before abort + micro',
+          'before abort + task',
+        ])
+      )
+    })
+
+    it('should suppress rejections from aborted prerender-client contexts', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = '1'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside store + before')
+        workUnitAsyncStorage.run(
+          {
+            type: 'prerender-client',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            Promise.reject('immediate abort + sync')
+            await 1
+            Promise.reject('immediate abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('immediate abort + task')
+          }
+        )
+        const delayedAbortStore = {
+          type: 'prerender-client',
+          renderSignal: { aborted: false },
+        }
+        workUnitAsyncStorage.run(
+          delayedAbortStore as WorkUnitStore,
+          async () => {
+            Promise.reject('before abort + sync')
+            await 1
+            Promise.reject('before abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('before abort + task')
+            await new Promise((r) => setImmediate(r))
+            // We mutate this after a task b/c in Next.js this is always done right at the beginning
+            // of a task and any promises rejecting in prior tasks would have already observed their
+            // rejections as unhandled without the aborted signal
+            delayedAbortStore.renderSignal.aborted = true
+            Promise.reject('after abort + sync')
+            await 1
+            Promise.reject('after abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('delayed abort + task')
+          }
+        )
+        Promise.reject('outside store + after')
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(
+        expect.arrayContaining([
+          'outside store + before',
+          'outside store + after',
+          'before abort + sync',
+          'before abort + micro',
+          'before abort + task',
+        ])
+      )
+    })
+
+    it('should suppress rejections from aborted prerender-runtime contexts', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = '1'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside store + before')
+        workUnitAsyncStorage.run(
+          {
+            type: 'prerender-runtime',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            Promise.reject('immediate abort + sync')
+            await 1
+            Promise.reject('immediate abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('immediate abort + task')
+          }
+        )
+        const delayedAbortStore = {
+          type: 'prerender-runtime',
+          renderSignal: { aborted: false },
+        }
+        workUnitAsyncStorage.run(
+          delayedAbortStore as WorkUnitStore,
+          async () => {
+            Promise.reject('before abort + sync')
+            await 1
+            Promise.reject('before abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('before abort + task')
+            await new Promise((r) => setImmediate(r))
+            // We mutate this after a task b/c in Next.js this is always done right at the beginning
+            // of a task and any promises rejecting in prior tasks would have already observed their
+            // rejections as unhandled without the aborted signal
+            delayedAbortStore.renderSignal.aborted = true
+            Promise.reject('after abort + sync')
+            await 1
+            Promise.reject('after abort + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('delayed abort + task')
+          }
+        )
+        Promise.reject('outside store + after')
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(
+        expect.arrayContaining([
+          'outside store + before',
+          'outside store + after',
+          'before abort + sync',
+          'before abort + micro',
+          'before abort + task',
+        ])
+      )
+    })
+
+    it('should pass through rejections from non-aborted prerender contexts', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = '1'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside store + before')
+        workUnitAsyncStorage.run(
+          {
+            type: 'request',
+          } as WorkUnitStore,
+          async () => {
+            Promise.reject('in store + sync')
+            await 1
+            Promise.reject('in store + micro')
+            await new Promise((r) => setTimeout(r, 10))
+            Promise.reject('in store + task')
+          }
+        )
+        Promise.reject('outside store + after')
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(
+        expect.arrayContaining([
+          'outside store + before',
+          'outside store + after',
+          'in store + sync',
+          'in store + micro',
+          'in store + task',
+        ])
+      )
+    })
+
+    it('should call console.error when no handlers are present', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = '1'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        console.error = (...args: Array<any>) => {
+          reportResult({ type: 'error-log', message: args.join(' ') })
+        }
+
+        Promise.reject('BOOM')
+      }
+
+      const { uhr, errorLog, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual([])
+      expect(errorLog).toEqual(['Unhandled Rejection: BOOM'])
+    })
+  })
+
+  describe('process method interception', () => {
+    it('should handle process.once listeners correctly', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        let callCount = 0
+
+        process.on('unhandledRejection', () => {
+          // this is a noop handler so that we don't trigger the default
+          // process killing behavior after the once handler is called
+        })
+
+        process.once('unhandledRejection', (reason) => {
+          callCount++
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('FIRST')
+        Promise.reject('SECOND')
+        Promise.reject('THIRD')
+        Promise.reject('FOURTH')
+        Promise.reject('FIFTH')
+
+        await new Promise((r) => setTimeout(r, 10))
+
+        reportResult({ type: 'count', count: callCount })
+      }
+
+      const { uhr, count, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(['FIRST'])
+      expect(count).toEqual([1])
+    })
+
+    it('should handle process.removeListener correctly', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const handler1 = (reason: unknown) => {
+          reportResult({ type: 'uhr', reason: `[1]: ${String(reason)}` })
+        }
+        const handler2 = (reason: unknown) => {
+          reportResult({ type: 'uhr', reason: `[2]: ${String(reason)}` })
+        }
+
+        process.on('unhandledRejection', handler1)
+        process.on('unhandledRejection', handler2)
+        process.once('unhandledRejection', handler1)
+        process.once('unhandledRejection', handler2)
+        process.addListener('unhandledRejection', handler1)
+        process.addListener('unhandledRejection', handler2)
+        process.prependListener('unhandledRejection', handler1)
+        process.prependListener('unhandledRejection', handler2)
+        process.prependOnceListener('unhandledRejection', handler1)
+        process.prependOnceListener('unhandledRejection', handler2)
+
+        process.off('unhandledRejection', handler1)
+        process.removeListener('unhandledRejection', handler1)
+
+        Promise.reject('BOOM1')
+        Promise.reject('BOOM2')
+        await new Promise((r) => setTimeout(r, 10))
+
+        process.off('unhandledRejection', handler1)
+        process.removeListener('unhandledRejection', handler1)
+        process.off('unhandledRejection', handler2)
+        process.removeListener('unhandledRejection', handler2)
+
+        Promise.reject('BOOM3')
+        await new Promise((r) => setTimeout(r, 10))
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual([
+        // ... BOOM1 time
+        '[2]: BOOM1', // prependOnceListener(... handler2)
+        '[1]: BOOM1', // prependOnceListener(... handler1)
+        '[2]: BOOM1', // prependListener(... handler2)
+        '[1]: BOOM1', // prependListener(... handler1)
+        '[1]: BOOM1', // process.on(... handler1)
+        '[2]: BOOM1', // process.on(... handler2)
+        // we removed the process.once(..., handler1)
+        '[2]: BOOM1', // process.once(... handler2)
+        // we removed the process.addListener(..., handler1)
+        '[2]: BOOM1', // process.addListener(... handler2)
+        // ... BOOM2 time
+        // once listeners are exhausted so they are skipped
+        '[2]: BOOM2', // prependListener(... handler2)
+        '[1]: BOOM2', // prependListener(... handler1)
+        '[1]: BOOM2', // process.on(... handler1)
+        '[2]: BOOM2', // process.on(... handler2)
+        // once listeners are exhausted so they are skipped
+        // we removed the process.addListener(..., handler1)
+        '[2]: BOOM2', // process.addListener(... handler2)
+        // ... BOOM3 time
+        // all handler1 handlers have been removed
+        '[2]: BOOM3', // prependListener(... handler2)
+        // all remaining handler2 handlers have been removed
+      ])
+    })
+
+    it('should uninstall filter when removeAllListeners() is called without arguments', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside 1')
+
+        await workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            await new Promise((r) => setImmediate(r))
+            Promise.reject('Should be filtered out')
+            await new Promise((r) => setImmediate(r))
+          }
+        )
+
+        process.removeAllListeners()
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside 2')
+
+        // After uninstalling, rejections that would be filtered should now pass through
+        await workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            await new Promise((r) => setImmediate(r))
+            Promise.reject('should not be filtered after uninstall')
+            await new Promise((r) => setImmediate(r))
+          }
+        )
+
+        Promise.reject('outside 3')
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual([
+        'outside 1',
+        'outside 2',
+        'should not be filtered after uninstall',
+        'outside 3',
+      ])
+    })
+
+    it('should not uninstall filter when removeAllListeners("unhandledRejection") is called', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside 1')
+
+        await workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            await new Promise((r) => setImmediate(r))
+            Promise.reject('Should be filtered out')
+            await new Promise((r) => setImmediate(r))
+          }
+        )
+
+        process.removeAllListeners('unhandledRejection')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: String(reason) })
+        })
+
+        Promise.reject('outside 2')
+
+        // After uninstalling, rejections that would be filtered should now pass through
+        await workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            await new Promise((r) => setImmediate(r))
+            Promise.reject('Should be filtered out')
+            await new Promise((r) => setImmediate(r))
+          }
+        )
+        Promise.reject('outside 3')
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(['outside 1', 'outside 2', 'outside 3'])
+    })
+
+    it('should not affect other listeners when adding/removing listeners from within a handler', async () => {
+      // This test asserts that our patch preserves node's semantics that all listeners registered when
+      // an event is emitted will be invoked regardless of whether there are mutations to the listeners
+      // during event handling.
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const onceHandler = (reason: unknown) => {
+          reportResult({ type: 'uhr', reason: `once: ${String(reason)}` })
+        }
+
+        const handler1 = (reason: unknown) => {
+          reportResult({
+            type: 'count',
+            count: process.listeners('unhandledRejection').length,
+          })
+          reportResult({ type: 'uhr', reason: `handler1: ${String(reason)}` })
+
+          // Try to add a new handler from within this handler
+          const appendHandler = (innerReason: unknown) => {
+            reportResult({
+              type: 'uhr',
+              reason: `append: ${String(innerReason)}`,
+            })
+          }
+          process.on('unhandledRejection', appendHandler)
+
+          const prependHandler = (innerReason: unknown) => {
+            reportResult({
+              type: 'uhr',
+              reason: `prepend: ${String(innerReason)}`,
+            })
+          }
+          process.prependListener('unhandledRejection', prependHandler)
+
+          // Try to remove handler3 from within this handler
+          process.removeListener('unhandledRejection', handler3)
+        }
+
+        const handler2 = (reason: unknown) => {
+          reportResult({ type: 'uhr', reason: `handler2: ${String(reason)}` })
+        }
+
+        const handler3 = (reason: unknown) => {
+          reportResult({ type: 'uhr', reason: `handler3: ${String(reason)}` })
+        }
+
+        const handler4 = (reason: unknown) => {
+          reportResult({ type: 'uhr', reason: `handler4: ${String(reason)}` })
+        }
+
+        // Add handlers in a specific order
+        process.once('unhandledRejection', onceHandler)
+        process.on('unhandledRejection', handler1) // This will modify listeners during execution
+        process.on('unhandledRejection', handler2) // Should always run
+        process.on('unhandledRejection', handler3) // Will be removed by handler1, but should still run for first event
+        process.on('unhandledRejection', handler4) // Should always run
+
+        // First rejection - all original handlers should run despite handler1 modifying the list
+        Promise.reject('first')
+        // Second rejection - handler3 should be gone, dynamic handler should be present
+        Promise.reject('second')
+        await new Promise((r) => setTimeout(r, 10))
+      }
+
+      const { uhr, count, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(count).toEqual([
+        // First event, the once handler is removed before execution so it no longer counts
+        // But we also have our filter handler
+        5,
+        // Then when the second event happens we have added 2 additional handlers and removed 1
+        // So the count increase by 1
+        6,
+      ])
+      expect(uhr).toEqual([
+        // First event - all original handlers should run
+        'once: first',
+        'handler1: first',
+        'handler2: first',
+        'handler3: first', // Still runs even though handler1 removed it
+        'handler4: first',
+
+        // Second event - handler3 is gone, dynamic handler is present
+        'prepend: second',
+        'handler1: second',
+        'handler2: second',
+        'handler4: second',
+        'append: second', // The dynamically added handler now runs
+      ])
+    })
+
+    it('should preserve native function toString behavior for patched process methods', async () => {
+      async function testForWorker() {
+        const originalMethods = [
+          process.on,
+          process.addListener,
+          process.off,
+          process.removeListener,
+          process.prependListener,
+          process.once,
+          process.prependOnceListener,
+          process.removeAllListeners,
+          process.listeners,
+        ]
+
+        const originalToStrings = originalMethods.map((m) => m.toString())
+        const originalNames = originalMethods.map((m) => m.name)
+
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const patchedMethods = [
+          process.on,
+          process.addListener,
+          process.off,
+          process.removeListener,
+          process.prependListener,
+          process.once,
+          process.prependOnceListener,
+          process.removeAllListeners,
+          process.listeners,
+        ]
+
+        const patchedToStrings = patchedMethods.map((m) => m.toString())
+        const patchedNames = patchedMethods.map((m) => m.name)
+
+        reportResult({
+          type: 'serialized',
+          key: 'originalToStrings',
+          data: JSON.stringify(originalToStrings),
+        })
+        reportResult({
+          type: 'serialized',
+          key: 'patchedToStrings',
+          data: JSON.stringify(patchedToStrings),
+        })
+        reportResult({
+          type: 'serialized',
+          key: 'originalNames',
+          data: JSON.stringify(originalNames),
+        })
+        reportResult({
+          type: 'serialized',
+          key: 'patchedNames',
+          data: JSON.stringify(patchedNames),
+        })
+      }
+
+      const { data, exitCode } = await runWorkerCode(testForWorker)
+
+      type ReportedData = {
+        originalToStrings: string[]
+        patchedToStrings: string[]
+        originalNames: string[]
+        patchedNames: string[]
+      }
+
+      const results = data as unknown as ReportedData
+
+      expect(results.originalNames).toEqual(results.patchedNames)
+      expect(results.originalToStrings).toEqual(results.patchedToStrings)
+      expect(exitCode).toBe(0)
+    })
+  })
+
+  describe('error handling in handlers', () => {
+    it('should handle errors thrown by user handlers gracefully', async () => {
+      async function testForWorker() {
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        process.on('unhandledRejection', () => {
+          throw new Error('Handler error')
+        })
+
+        process.once('uncaughtException', (error) => {
+          reportResult({ type: 'error', message: error.message })
+        })
+
+        workUnitAsyncStorage.run(
+          { type: 'request' } as WorkUnitStore,
+          async () => {
+            Promise.reject(new Error('Original error'))
+            await new Promise((r) => setImmediate(r))
+            await new Promise((r) => setTimeout(r, 10))
+          }
+        )
+      }
+
+      const { messages, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(messages).toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({ type: 'error', message: 'Handler error' }),
+        ])
+      )
+    })
+  })
+
+  describe('integration with existing listeners', () => {
+    it('should capture and preserve existing listeners during installation', async () => {
+      async function testForWorker() {
+        // Add listener before installing filter
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: `existing: ${String(reason)}` })
+        })
+
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        // Test non-filtered rejection
+        workUnitAsyncStorage.run(
+          { type: 'request' } as WorkUnitStore,
+          async () => {
+            Promise.reject('passes through')
+            await new Promise((r) => setTimeout(r, 10))
+          }
+        )
+
+        // Test filtered rejection
+        workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            Promise.reject('should be filtered')
+            await new Promise((r) => setTimeout(r, 10))
+          }
+        )
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(['existing: passes through'])
+    })
+
+    it('should be able to clear listeners that existed prior to installation', async () => {
+      async function testForWorker() {
+        // Add listener before installing filter
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: `existing: ${String(reason)}` })
+        })
+
+        process.env.NEXT_USE_UNHANDLED_REJECTION_FILTER = 'enabled'
+        require('next/dist/server/node-environment-extensions/unhandled-rejection')
+
+        process.removeAllListeners('unhandledRejection')
+
+        process.on('unhandledRejection', (reason) => {
+          reportResult({ type: 'uhr', reason: `after: ${String(reason)}` })
+        })
+
+        const {
+          workUnitAsyncStorage,
+        } = require('next/dist/server/app-render/work-unit-async-storage.external')
+
+        // Test non-filtered rejection
+        workUnitAsyncStorage.run(
+          { type: 'request' } as WorkUnitStore,
+          async () => {
+            Promise.reject('passes through')
+            await new Promise((r) => setTimeout(r, 10))
+          }
+        )
+
+        // Test filtered rejection
+        workUnitAsyncStorage.run(
+          {
+            type: 'prerender',
+            renderSignal: { aborted: true },
+          } as WorkUnitStore,
+          async () => {
+            Promise.reject('should be filtered')
+            await new Promise((r) => setTimeout(r, 10))
+          }
+        )
+      }
+
+      const { uhr, exitCode } = await runWorkerCode(testForWorker)
+
+      expect(exitCode).toBe(0)
+      expect(uhr).toEqual(['after: passes through'])
+    })
+  })
+})

filename: packages/next/src/server/node-environment-extensions/unhandled-rejection.tsx
patch: @@ -76,19 +76,70 @@ let underlyingListeners: Array<NodeJS.UnhandledRejectionListener> = []
 // details like whether the listener is a once listener.
 let listenerMetadata: Array<ListenerMetadata> = []
 
-let originalProcessOn: typeof process.on
+// These methods are used to restore the original implementations when uninstalling the patch
 let originalProcessAddListener: typeof process.addListener
-let originalProcessOnce: typeof process.once
 let originalProcessRemoveListener: typeof process.removeListener
-let originalProcessRemoveAllListeners: typeof process.removeAllListeners
-let originalProcessListeners: typeof process.listeners
+let originalProcessOn: typeof process.on
+let originalProcessOff: typeof process.off
 let originalProcessPrependListener: typeof process.prependListener
+let originalProcessOnce: typeof process.once
 let originalProcessPrependOnceListener: typeof process.prependOnceListener
-let originalProcessOff: typeof process.off
+let originalProcessRemoveAllListeners: typeof process.removeAllListeners
+let originalProcessListeners: typeof process.listeners
+
+type UnderlyingMethod =
+  | typeof originalProcessAddListener
+  | typeof originalProcessRemoveListener
+  | typeof originalProcessOn
+  | typeof originalProcessOff
+  | typeof originalProcessPrependListener
+  | typeof originalProcessOnce
+  | typeof originalProcessPrependOnceListener
+  | typeof originalProcessRemoveAllListeners
+  | typeof originalProcessListeners
 
 let didWarnPrepend = false
 let didWarnRemoveAll = false
 
+// Some of these base methods call others and we don't want them to call the patched version so we
+// need a way to synchronously disable the patch temporarily.
+let bypassPatch = false
+
+// This patch ensures that if any patched methods end up calling other methods internally they will
+// bypass the patch during their execution. This is important for removeAllListeners in particular
+// because it calls removeListener internally and we want to ensure it actually clears the listeners
+// from the process queue and not our private queue.
+function patchWithoutReentrancy<T extends UnderlyingMethod>(
+  original: T,
+  patchedImpl: T
+): T {
+  // Produce a function which has the correct name
+  const patched = {
+    [original.name]: function (...args: Parameters<T>) {
+      if (bypassPatch) {
+        return Reflect.apply(original, process, args)
+      }
+
+      const previousBypassPatch = bypassPatch
+      bypassPatch = true
+      try {
+        return Reflect.apply(patchedImpl, process, args)
+      } finally {
+        bypassPatch = previousBypassPatch
+      }
+    } as any,
+  }[original.name]
+
+  // Preserve the original toString behavior
+  Object.defineProperty(patched, 'toString', {
+    value: original.toString.bind(original),
+    writable: true,
+    configurable: true,
+  })
+
+  return patched
+}
+
 /**
  * Installs a filtering unhandled rejection handler that intelligently suppresses
  * rejections from aborted prerender contexts.
@@ -114,67 +165,118 @@ function installUnhandledRejectionFilter(): void {
     once: false,
   }))
 
+  // Remove all existing handlers
+  process.removeAllListeners('unhandledRejection')
+
+  // Install our filtering handler
+  process.addListener('unhandledRejection', filteringUnhandledRejectionHandler)
+
   // Store the original process methods
-  originalProcessOn = process.on
   originalProcessAddListener = process.addListener
-  originalProcessOnce = process.once
-  originalProcessOff = process.off
   originalProcessRemoveListener = process.removeListener
-  originalProcessRemoveAllListeners = process.removeAllListeners
-  originalProcessListeners = process.listeners
+  originalProcessOn = process.on
+  originalProcessOff = process.off
   originalProcessPrependListener = process.prependListener
+  originalProcessOnce = process.once
   originalProcessPrependOnceListener = process.prependOnceListener
+  originalProcessRemoveAllListeners = process.removeAllListeners
+  originalProcessListeners = process.listeners
 
-  // Helper function to create a patched method that preserves toString behavior
-  function patchMethod<T extends Function>(original: T, patchedImpl: T): T {
-    // Preserve the original toString behavior
-    Object.defineProperty(patchedImpl, 'toString', {
-      value: original.toString.bind(original),
-      writable: true,
-      configurable: true,
-    })
-    return patchedImpl
-  }
+  process.addListener = patchWithoutReentrancy(
+    originalProcessAddListener,
+    function (event: string | symbol, listener: (...args: any[]) => void) {
+      if (event === 'unhandledRejection') {
+        debug?.('process.addListener', listener.toString())
+        // Add new handlers to our internal queue instead of the process
+        underlyingListeners.push(listener as NodeJS.UnhandledRejectionListener)
+        listenerMetadata.push({ listener, once: false })
+        return process
+      }
+      // For other events, use the original method
+      return originalProcessAddListener.call(process, event as any, listener)
+    } as typeof process.addListener
+  )
 
-  // Intercept process.on to capture new unhandled rejection handlers
-  process.on = patchMethod(originalProcessOn, function (
-    event: string | symbol,
-    listener: (...args: any[]) => void
-  ) {
-    if (event === 'unhandledRejection') {
-      debug?.('process.on/addListener', listener.toString())
-      // Add new handlers to our internal queue instead of the process
-      underlyingListeners.push(listener as NodeJS.UnhandledRejectionListener)
-      listenerMetadata.push({ listener, once: false })
-      return process
-    }
-    // For other events, use the original method
-    return originalProcessOn.call(process, event, listener)
-  } as typeof process.on)
+  // Intercept process.removeListener (alias for process.off)
+  process.removeListener = patchWithoutReentrancy(
+    originalProcessRemoveListener,
+    function (event: string | symbol, listener: (...args: any[]) => void) {
+      if (event === 'unhandledRejection') {
+        debug?.('process.removeListener', listener.toString())
+        // Check if they're trying to remove our filtering handler
+        if (listener === filteringUnhandledRejectionHandler) {
+          uninstallUnhandledRejectionFilter()
+          return process
+        }
 
-  // Intercept process.addListener (alias for process.on)
-  process.addListener = patchMethod(
-    originalProcessAddListener,
-    process.on as typeof originalProcessAddListener
+        const index = underlyingListeners.lastIndexOf(listener)
+        if (index > -1) {
+          debug?.('process.removeListener match found', index)
+          underlyingListeners.splice(index, 1)
+          listenerMetadata.splice(index, 1)
+        } else {
+          debug?.('process.removeListener match not found', index)
+        }
+        return process
+      }
+      // For other events, use the original method
+      return originalProcessRemoveListener.call(process, event, listener)
+    } as typeof process.off
   )
 
-  // Intercept process.once for one-time handlers
-  process.once = patchMethod(originalProcessOnce, function (
-    event: string | symbol,
-    listener: (...args: any[]) => void
-  ) {
-    if (event === 'unhandledRejection') {
-      debug?.('process.once', listener.toString())
-      underlyingListeners.push(listener)
-      listenerMetadata.push({ listener, once: true })
-      return process
-    }
-    // For other events, use the original method
-    return originalProcessOnce.call(process, event, listener)
-  } as typeof process.once)
+  // If the process.on is referentially process.addListener then share the patched version as well
+  if (originalProcessOn === originalProcessAddListener) {
+    process.on = process.addListener
+  } else {
+    process.on = patchWithoutReentrancy(originalProcessOn, function (
+      event: string | symbol,
+      listener: (...args: any[]) => void
+    ) {
+      if (event === 'unhandledRejection') {
+        debug?.('process.on', listener.toString())
+        // Add new handlers to our internal queue instead of the process
+        underlyingListeners.push(listener as NodeJS.UnhandledRejectionListener)
+        listenerMetadata.push({ listener, once: false })
+        return process
+      }
+      // For other events, use the original method
+      return originalProcessOn.call(process, event, listener)
+    } as typeof process.on)
+  }
+
+  // If the process.off is referentially process.addListener then share the patched version as well
+  if (originalProcessOff === originalProcessRemoveListener) {
+    process.off = process.removeListener
+  } else {
+    process.off = patchWithoutReentrancy(originalProcessOff, function (
+      event: string | symbol,
+      listener: (...args: any[]) => void
+    ) {
+      if (event === 'unhandledRejection') {
+        debug?.('process.off', listener.toString())
+        // Check if they're trying to remove our filtering handler
+        if (listener === filteringUnhandledRejectionHandler) {
+          uninstallUnhandledRejectionFilter()
+          return process
+        }
+
+        const index = underlyingListeners.lastIndexOf(listener)
+        if (index > -1) {
+          debug?.('process.off match found', index)
+          underlyingListeners.splice(index, 1)
+          listenerMetadata.splice(index, 1)
+        } else {
+          debug?.('process.off match not found', index)
+        }
+        return process
+      }
+      // For other events, use the original method
+      return originalProcessOff.call(process, event, listener)
+    } as typeof process.off)
+  }
 
   // Intercept process.prependListener for handlers that should go first
-  process.prependListener = patchMethod(
+  process.prependListener = patchWithoutReentrancy(
     originalProcessPrependListener,
     function (event: string | symbol, listener: (...args: any[]) => void) {
       if (event === 'unhandledRejection') {
@@ -202,8 +304,26 @@ function installUnhandledRejectionFilter(): void {
     } as typeof process.prependListener
   )
 
+  // Intercept process.once for one-time handlers
+  process.once = patchWithoutReentrancy(originalProcessOnce, function (
+    event: string | symbol,
+    listener: (...args: any[]) => void
+  ) {
+    if (event === 'unhandledRejection') {
+      debug?.('process.once', listener.toString())
+      underlyingListeners.push(listener as NodeJS.UnhandledRejectionListener)
+      listenerMetadata.push({
+        listener: listener as NodeJS.UnhandledRejectionListener,
+        once: true,
+      })
+      return process
+    }
+    // For other events, use the original method
+    return originalProcessOnce.call(process, event, listener)
+  } as typeof process.once)
+
   // Intercept process.prependOnceListener for one-time handlers that should go first
-  process.prependOnceListener = patchMethod(
+  process.prependOnceListener = patchWithoutReentrancy(
     originalProcessPrependOnceListener,
     function (event: string | symbol, listener: (...args: any[]) => void) {
       if (event === 'unhandledRejection') {
@@ -216,8 +336,13 @@ function installUnhandledRejectionFilter(): void {
           )
         }
         // Add to the beginning of our internal queue
-        underlyingListeners.unshift(listener)
-        listenerMetadata.unshift({ listener, once: true })
+        underlyingListeners.unshift(
+          listener as NodeJS.UnhandledRejectionListener
+        )
+        listenerMetadata.unshift({
+          listener: listener as NodeJS.UnhandledRejectionListener,
+          once: true,
+        })
         return process
       }
       // For other events, use the original method
@@ -229,41 +354,8 @@ function installUnhandledRejectionFilter(): void {
     } as typeof process.prependOnceListener
   )
 
-  // Intercept process.removeListener
-  process.removeListener = patchMethod(originalProcessRemoveListener, function (
-    event: string | symbol,
-    listener: (...args: any[]) => void
-  ) {
-    if (event === 'unhandledRejection') {
-      debug?.('process.removeListener', listener.toString())
-      // Check if they're trying to remove our filtering handler
-      if (listener === filteringUnhandledRejectionHandler) {
-        uninstallUnhandledRejectionFilter()
-        return process
-      }
-
-      const index = underlyingListeners.lastIndexOf(listener)
-      if (index > -1) {
-        debug?.('process.removeListener match found', index)
-        underlyingListeners.splice(index, 1)
-        listenerMetadata.splice(index, 1)
-      } else {
-        debug?.('process.removeListener match not found', index)
-      }
-      return process
-    }
-    // For other events, use the original method
-    return originalProcessRemoveListener.call(process, event, listener)
-  } as typeof process.removeListener)
-
-  // Intercept process.off (alias for process.removeListener)
-  process.off = patchMethod(
-    originalProcessOff,
-    process.removeListener as typeof originalProcessOff
-  )
-
   // Intercept process.removeAllListeners
-  process.removeAllListeners = patchMethod(
+  process.removeAllListeners = patchWithoutReentrancy(
     originalProcessRemoveAllListeners,
     function (event?: string | symbol) {
       if (event === 'unhandledRejection') {
@@ -301,33 +393,22 @@ function installUnhandledRejectionFilter(): void {
   )
 
   // Intercept process.listeners to return our internal handlers for unhandled rejection
-  process.listeners = patchMethod(originalProcessListeners, function (
-    event: string | symbol
-  ) {
-    if (event === 'unhandledRejection') {
-      debug?.(
-        'process.listeners',
-        [filteringUnhandledRejectionHandler, ...underlyingListeners].map((l) =>
-          l.toString()
+  process.listeners = patchWithoutReentrancy(
+    originalProcessListeners,
+    function (event: string | symbol) {
+      if (event === 'unhandledRejection') {
+        debug?.(
+          'process.listeners',
+          [filteringUnhandledRejectionHandler, ...underlyingListeners].map(
+            (l) => l.toString()
+          )
         )
-      )
-      return [filteringUnhandledRejectionHandler, ...underlyingListeners]
-    }
-    return originalProcessListeners.call(process, event as any)
-  } as typeof process.listeners)
-
-  // Remove all existing handlers
-  originalProcessRemoveAllListeners.call(process, 'unhandledRejection')
-
-  // Install our filtering handler
-  originalProcessOn.call(
-    process,
-    'unhandledRejection',
-    filteringUnhandledRejectionHandler
+        return [filteringUnhandledRejectionHandler, ...underlyingListeners]
+      }
+      return originalProcessListeners.call(process, event as any)
+    } as typeof process.listeners
   )
 
-  originalProcessOn.call(process, 'rejectionHandled', noopRejectionHandled)
-
   filterInstalled = true
 
   debug?.(
@@ -373,24 +454,17 @@ function uninstallUnhandledRejectionFilter(): void {
   process.listeners = originalProcessListeners
 
   // Remove our filtering handler
-  originalProcessRemoveListener.call(
-    process,
+  process.removeListener(
     'unhandledRejection',
     filteringUnhandledRejectionHandler
   )
 
-  originalProcessRemoveListener.call(
-    process,
-    'rejectionHandled',
-    noopRejectionHandled
-  )
-
   // Re-register all the handlers that were in our internal queue
   for (const meta of listenerMetadata) {
     if (meta.once) {
-      originalProcessOnce.call(process, 'unhandledRejection', meta.listener)
+      process.once('unhandledRejection', meta.listener)
     } else {
-      originalProcessOn.call(process, 'unhandledRejection', meta.listener)
+      process.addListener('unhandledRejection', meta.listener)
     }
   }
 
@@ -473,8 +547,6 @@ function filteringUnhandledRejectionHandler(
   }
 }
 
-function noopRejectionHandled() {}
-
 // Install the filter when this module is imported
 if (ENABLE_UHR_FILTER) {
   installUnhandledRejectionFilter()