Files
next.js/test/unit/web-sandbox-resource-managers.test.ts
d2ee326a0b fix(sandbox): release one-shot timeout ids after they run (#96161)
### What?

Fire-and-forget `setTimeout` calls in the Edge runtime sandbox (e.g.
from middleware) are tracked forever by Next.js's sandbox
`TimeoutsManager`, even after the timeout has run. In a long-lived
standalone/self-hosted server this makes tracked timeout resources grow
monotonically, producing step-like heap growth and eventual OOM.

### Why?

`TimeoutsManager.add()` pushes each timeout id into a `resources` array.
When a one-shot timeout fires naturally, `webSetTimeoutPolyfill`'s
`finally` clears the underlying Node timer (the workaround for
nodejs/node#53335) but never removes the id from the manager. The id is
dropped only if user code calls `clearTimeout(id)` or the whole module
context is torn down (`clearModuleContext`, introduced in #57235). The
natural-completion path — module context stays alive, timeout finishes
on its own — was missing, so ids accumulate for the lifetime of the
process.

### How?

- `TimeoutsManager.create()` now wraps the callback so the timeout
untracks its own id once it has run. The Node-timer workaround and the
`this`/args binding in `webSetTimeoutPolyfill` are preserved.
- Refactored the base `ResourceManager.remove()` into `untrack()` (stop
tracking) + `destroy()`, so natural completion can release tracking
without a redundant `clearTimeout`.
- Intervals are intentionally unchanged: they fire repeatedly and must
remain tracked until `clearInterval`/`removeAll`.
- Added a unit test covering natural release, no monotonic growth across
repeated rounds, the `clearTimeout`/`remove` path, callback `this`/args
binding, and that intervals stay tracked.

Verified: `pnpm --filter=next types` clean, prettier/eslint clean, and
the new unit test fails on the current behavior (reverting only the
release logic) and passes with the fix.

Fixes #95094

---

**Update:** this branch now lands @berry95's commit from #94794 first
(`Set`-based resource tracking, which also makes `untrack`/`remove`
O(1)), with the fix rebased on top.

---------

Co-authored-by: Berry Kim <[email protected]>
Co-authored-by: Dan Abramov <[email protected]>
2026-07-25 19:42:17 +01:00

164 lines
4.7 KiB
TypeScript

/* eslint-env jest */
import {
IntervalsManager,
TimeoutsManager,
} from '../../packages/next/src/server/web/sandbox/resource-managers'
// A fake edge-runtime global object; the managers only use it as the `this`
// value passed to timer callbacks.
const globalObject = {} as any
async function waitFor(condition: () => boolean, timeoutMs = 1000) {
const start = Date.now()
while (!condition()) {
if (Date.now() - start > timeoutMs) {
throw new Error('waitFor timed out')
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
}
describe('web sandbox TimeoutsManager', () => {
it('releases one-shot timeout ids after they run (regression: #95094)', async () => {
const manager = new TimeoutsManager()
let ran = 0
const increment = () => {
ran++
}
const count = 50
for (let i = 0; i < count; i++) {
// Fire-and-forget: the returned id is never passed to clearTimeout,
// matching common middleware usage.
manager.add([globalObject, increment, 0])
}
// All are tracked before they fire.
expect(manager.size).toBe(count)
// After they complete naturally, tracking must drop back to zero even
// though user code never called clearTimeout.
await waitFor(() => ran === count)
await waitFor(() => manager.size === 0)
expect(manager.size).toBe(0)
})
it('does not grow tracked resources across successive fire-and-forget timeouts', async () => {
const manager = new TimeoutsManager()
for (let round = 0; round < 5; round++) {
for (let i = 0; i < 20; i++) {
manager.add([globalObject, () => {}, 0])
}
await waitFor(() => manager.size === 0)
}
expect(manager.size).toBe(0)
})
it('invokes the callback with the provided extra args and global `this`', async () => {
const manager = new TimeoutsManager()
let observedThis: unknown
const observedArgs: unknown[] = []
manager.add([
globalObject,
function (this: unknown, ...args: unknown[]) {
observedThis = this
observedArgs.push(...args)
},
0,
'a',
'b',
])
await waitFor(() => observedArgs.length > 0)
expect(observedThis).toBe(globalObject)
expect(observedArgs).toEqual(['a', 'b'])
expect(manager.size).toBe(0)
})
it('releases the id even when the callback throws', () => {
jest.useFakeTimers()
try {
const manager = new TimeoutsManager()
const id = manager.add([
globalObject,
() => {
throw new Error('boom')
},
0,
])
expect(typeof id).toBe('number')
expect(manager.size).toBe(1)
expect(() => jest.runAllTimers()).toThrow('boom')
expect(manager.size).toBe(0)
} finally {
jest.useRealTimers()
}
})
it('clearing an already-fired timeout is a no-op', async () => {
const manager = new TimeoutsManager()
let ran = false
const firedId = manager.add([globalObject, () => (ran = true), 0])
const pendingId = manager.add([globalObject, () => {}, 1000])
await waitFor(() => ran)
await waitFor(() => manager.size === 1)
// Matches user code that calls clearTimeout inside the callback (the
// documented workaround for #95094); must not throw or affect other
// tracked timeouts.
manager.remove(firedId)
expect(manager.size).toBe(1)
manager.remove(pendingId)
expect(manager.size).toBe(0)
})
it('remove() cancels a pending timeout and stops tracking it', async () => {
const manager = new TimeoutsManager()
let ran = false
const id = manager.add([globalObject, () => (ran = true), 50])
expect(manager.size).toBe(1)
manager.remove(id)
expect(manager.size).toBe(0)
// Give the original delay time to elapse; the callback must not run.
await new Promise((resolve) => setTimeout(resolve, 100))
expect(ran).toBe(false)
})
it('removeAll() clears everything', async () => {
const manager = new TimeoutsManager()
manager.add([globalObject, () => {}, 1000])
manager.add([globalObject, () => {}, 1000])
expect(manager.size).toBe(2)
manager.removeAll()
expect(manager.size).toBe(0)
})
})
describe('web sandbox IntervalsManager', () => {
it('keeps tracking intervals until they are explicitly removed', async () => {
const manager = new IntervalsManager()
let ticks = 0
const id = manager.add([globalObject, () => ticks++, 10])
expect(manager.size).toBe(1)
// Intervals fire repeatedly, so they must remain tracked (unlike one-shot
// timeouts) even after several ticks have run.
await waitFor(() => ticks >= 3)
expect(manager.size).toBe(1)
manager.remove(id)
expect(manager.size).toBe(0)
})
})