Skip to content

Commit fbc9a2b

Browse files
authored
[Cache Components] Atomic setTimeouts (#86093)
This PR exploits some Node internals to make sure that, if we schedule some timeouts back to back to perform staged rendering, they'll actually run in the same iteration of the event loop. If we cannot patch the timeouts correctly (due to runtime differences in e.g. Bun, or changes in future node versions), we'll warn that Cache Components may not function correctly, and run the timers normally. timer/immediate interleaving is rare in practice, and we'll usually be fine even without this patch, so it's better to warn and try to carry on rather than to error out. (h/t to @sokra for finding this trick)
1 parent 844ec47 commit fbc9a2b

File tree

4 files changed

+230
-21
lines changed

4 files changed

+230
-21
lines changed

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,5 +930,8 @@
930930
"929": "No pages or app directory found.",
931931
"930": "Expected a dynamic route, but got a static route: %s",
932932
"931": "Unexpected empty path segments match for a route \"%s\" with param \"%s\" of type \"%s\"",
933-
"932": "Could not resolve param value for segment: %s"
933+
"932": "Could not resolve param value for segment: %s",
934+
"933": "An unexpected error occurred while adjusting `_idleStart` on an atomic timer",
935+
"934": "createAtomicTimerGroup cannot be called in the edge runtime",
936+
"935": "Cannot schedule more timers into a group that already executed"
934937
}

packages/next/src/server/app-render/app-render-prerender-utils.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InvariantError } from '../../shared/lib/invariant-error'
2+
import { createAtomicTimerGroup } from './app-render-scheduling'
23

34
/**
45
* This is a utility function to make scheduling sequential tasks that run back to back easier.
@@ -14,19 +15,22 @@ export function prerenderAndAbortInSequentialTasks<R>(
1415
)
1516
} else {
1617
return new Promise((resolve, reject) => {
18+
const scheduleTimeout = createAtomicTimerGroup()
19+
1720
let pendingResult: Promise<R>
18-
setTimeout(() => {
21+
scheduleTimeout(() => {
1922
try {
2023
pendingResult = prerender()
2124
pendingResult.catch(() => {})
2225
} catch (err) {
2326
reject(err)
2427
}
25-
}, 0)
26-
setTimeout(() => {
28+
})
29+
30+
scheduleTimeout(() => {
2731
abort()
2832
resolve(pendingResult)
29-
}, 0)
33+
})
3034
})
3135
}
3236
}
@@ -46,22 +50,26 @@ export function prerenderAndAbortInSequentialTasksWithStages<R>(
4650
)
4751
} else {
4852
return new Promise((resolve, reject) => {
53+
const scheduleTimeout = createAtomicTimerGroup()
54+
4955
let pendingResult: Promise<R>
50-
setTimeout(() => {
56+
scheduleTimeout(() => {
5157
try {
5258
pendingResult = prerender()
5359
pendingResult.catch(() => {})
5460
} catch (err) {
5561
reject(err)
5662
}
57-
}, 0)
58-
setTimeout(() => {
63+
})
64+
65+
scheduleTimeout(() => {
5966
advanceStage()
60-
}, 0)
61-
setTimeout(() => {
67+
})
68+
69+
scheduleTimeout(() => {
6270
abort()
6371
resolve(pendingResult)
64-
}, 0)
72+
})
6573
})
6674
}
6775
}

packages/next/src/server/app-render/app-render-render-utils.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InvariantError } from '../../shared/lib/invariant-error'
2+
import { createAtomicTimerGroup } from './app-render-scheduling'
23

34
/**
45
* This is a utility function to make scheduling sequential tasks that run back to back easier.
@@ -14,18 +15,21 @@ export function scheduleInSequentialTasks<R>(
1415
)
1516
} else {
1617
return new Promise((resolve, reject) => {
18+
const scheduleTimeout = createAtomicTimerGroup()
19+
1720
let pendingResult: R | Promise<R>
18-
setTimeout(() => {
21+
scheduleTimeout(() => {
1922
try {
2023
pendingResult = render()
2124
} catch (err) {
2225
reject(err)
2326
}
24-
}, 0)
25-
setTimeout(() => {
27+
})
28+
29+
scheduleTimeout(() => {
2630
followup()
2731
resolve(pendingResult)
28-
}, 0)
32+
})
2933
})
3034
}
3135
}
@@ -46,19 +50,21 @@ export function pipelineInSequentialTasks<A, B, C>(
4650
)
4751
} else {
4852
return new Promise((resolve, reject) => {
53+
const scheduleTimeout = createAtomicTimerGroup()
54+
4955
let oneResult: A | undefined = undefined
50-
setTimeout(() => {
56+
scheduleTimeout(() => {
5157
try {
5258
oneResult = one()
5359
} catch (err) {
5460
clearTimeout(twoId)
5561
clearTimeout(threeId)
5662
reject(err)
5763
}
58-
}, 0)
64+
})
5965

6066
let twoResult: B | undefined = undefined
61-
const twoId = setTimeout(() => {
67+
const twoId = scheduleTimeout(() => {
6268
// if `one` threw, then this timeout would've been cleared,
6369
// so if we got here, we're guaranteed to have a value.
6470
try {
@@ -67,17 +73,17 @@ export function pipelineInSequentialTasks<A, B, C>(
6773
clearTimeout(threeId)
6874
reject(err)
6975
}
70-
}, 0)
76+
})
7177

72-
const threeId = setTimeout(() => {
78+
const threeId = scheduleTimeout(() => {
7379
// if `two` threw, then this timeout would've been cleared,
7480
// so if we got here, we're guaranteed to have a value.
7581
try {
7682
resolve(three(twoResult!))
7783
} catch (err) {
7884
reject(err)
7985
}
80-
}, 0)
86+
})
8187
})
8288
}
8389
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { InvariantError } from '../../shared/lib/invariant-error'
2+
3+
/*
4+
==========================
5+
| Background |
6+
==========================
7+
8+
Node.js does not guarantee that two timers scheduled back to back will run
9+
on the same iteration of the event loop:
10+
11+
```ts
12+
setTimeout(one, 0)
13+
setTimeout(two, 0)
14+
```
15+
16+
Internally, each timer is assigned a `_idleStart` property that holds
17+
an internal libuv timestamp in millisecond resolution.
18+
This will be used to determine if the timer is already "expired" and should be executed.
19+
However, even in sync code, it's possible for two timers to get different `_idleStart` values.
20+
This can cause one of the timers to be executed, and the other to be delayed until the next timer phase.
21+
22+
The delaying happens [here](https://github.com/nodejs/node/blob/c208ffc66bb9418ff026c4e3fa82e5b4387bd147/lib/internal/timers.js#L556-L564).
23+
and can be debugged by running node with `NODE_DEBUG=timer`.
24+
25+
The easiest way to observe it is to run this program in a loop until it exits with status 1:
26+
27+
```
28+
// test.js
29+
30+
let immediateRan = false
31+
const t1 = setTimeout(() => {
32+
console.log('timeout 1')
33+
setImmediate(() => {
34+
console.log('immediate 1')
35+
immediateRan = true
36+
})
37+
})
38+
39+
const t2 = setTimeout(() => {
40+
console.log('timeout 2')
41+
if (immediateRan) {
42+
console.log('immediate ran before the second timeout!')
43+
console.log(
44+
`t1._idleStart: ${t1._idleStart}, t2_idleStart: ${t2._idleStart}`
45+
);
46+
process.exit(1)
47+
}
48+
})
49+
```
50+
51+
```bash
52+
#!/usr/bin/env bash
53+
54+
i=1;
55+
while true; do
56+
output="$(NODE_DEBUG=timer node test.js 2>&1)";
57+
if [ "$?" -eq 1 ]; then
58+
echo "failed after $i iterations";
59+
echo "$output";
60+
break;
61+
fi;
62+
i=$((i+1));
63+
done
64+
```
65+
66+
If `t2` is deferred to the next iteration of the event loop,
67+
then the immediate scheduled from inside `t1` will run first.
68+
When this occurs, `_idleStart` is reliably different between `t1` and `t2`.
69+
70+
==========================
71+
| Solution |
72+
==========================
73+
74+
We can guarantee that multiple timers (with the same delay, usually `0`)
75+
run together without any delays by making sure that their `_idleStart`s are the same,
76+
because that's what's used to determine if a timer should be deferred or not.
77+
Luckily, this property is currently exposed to userland and mutable,
78+
so we can patch it.
79+
80+
Another related trick we could potentially apply is making
81+
a timer immediately be considered expired by doing `timer._idleStart -= 2`.
82+
(the value must be more than `1`, the delay that actually gets set for `setTimeout(cb, 0)`).
83+
This makes node view this timer as "a 1ms timer scheduled 2ms ago",
84+
meaning that it should definitely run in the next timer phase.
85+
However, I'm not confident we know all the side effects of doing this,
86+
so for now, simply ensuring coordination is enough.
87+
*/
88+
89+
let shouldAttemptPatching = true
90+
91+
function warnAboutTimers() {
92+
console.warn(
93+
"Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of `setTimeout()`.\nPlease report a github issue here: https://github.com/vercel/next.js/issues/new/"
94+
)
95+
}
96+
97+
/**
98+
* Allows scheduling multiple timers (equivalent to `setTimeout(cb, delayMs)`)
99+
* that are guaranteed to run in the same iteration of the event loop.
100+
*
101+
* @param delayMs - the delay to pass to `setTimeout`. (default: 0)
102+
*
103+
* */
104+
export function createAtomicTimerGroup(delayMs = 0) {
105+
if (process.env.NEXT_RUNTIME === 'edge') {
106+
throw new InvariantError(
107+
'createAtomicTimerGroup cannot be called in the edge runtime'
108+
)
109+
} else {
110+
let isFirstCallback = true
111+
let firstTimerIdleStart: number | null = null
112+
let didFirstTimerRun = false
113+
114+
// As a sanity check, we schedule an immediate from the first timeout
115+
// to check if the execution was interrupted.
116+
let didImmediateRun = false
117+
function runFirstCallback(callback: () => void) {
118+
didFirstTimerRun = true
119+
if (shouldAttemptPatching) {
120+
setImmediate(() => {
121+
didImmediateRun = true
122+
})
123+
}
124+
return callback()
125+
}
126+
127+
function runSubsequentCallback(callback: () => void) {
128+
if (shouldAttemptPatching) {
129+
if (didImmediateRun) {
130+
// If the immediate managed to run between the timers, then we're not
131+
// able to provide the guarantees that we're supposed to
132+
shouldAttemptPatching = false
133+
warnAboutTimers()
134+
}
135+
}
136+
return callback()
137+
}
138+
139+
return function scheduleTimeout(callback: () => void) {
140+
if (didFirstTimerRun) {
141+
throw new InvariantError(
142+
'Cannot schedule more timers into a group that already executed'
143+
)
144+
}
145+
146+
const timer = setTimeout(
147+
isFirstCallback ? runFirstCallback : runSubsequentCallback,
148+
delayMs,
149+
callback
150+
)
151+
isFirstCallback = false
152+
153+
if (!shouldAttemptPatching) {
154+
// We already tried patching some timers, and it didn't work.
155+
// No point trying again.
156+
return timer
157+
}
158+
159+
// NodeJS timers have a `_idleStart` property, but it doesn't exist e.g. in Bun.
160+
// If it's not present, we'll warn and try to continue.
161+
try {
162+
if ('_idleStart' in timer && typeof timer._idleStart === 'number') {
163+
// If this is the first timer that was scheduled, save its `_idleStart`.
164+
// We'll copy it onto subsequent timers to guarantee that they'll all be
165+
// considered expired in the same iteration of the event loop
166+
// and thus will all be executed in the same timer phase.
167+
if (firstTimerIdleStart === null) {
168+
firstTimerIdleStart = timer._idleStart
169+
} else {
170+
timer._idleStart = firstTimerIdleStart
171+
}
172+
} else {
173+
shouldAttemptPatching = false
174+
warnAboutTimers()
175+
}
176+
} catch (err) {
177+
// This should never fail in current Node, but it might start failing in the future.
178+
// We might be okay even without tweaking the timers, so warn and try to continue.
179+
console.error(
180+
new InvariantError(
181+
'An unexpected error occurred while adjusting `_idleStart` on an atomic timer',
182+
{ cause: err }
183+
)
184+
)
185+
shouldAttemptPatching = false
186+
warnAboutTimers()
187+
}
188+
189+
return timer
190+
}
191+
}
192+
}

0 commit comments

Comments
 (0)