Skip to content

Commit 71086a4

Browse files
committed
process: add deferTick
Adds a new scheduling primitive to resolve zaldo when mixing traditional Node async programming with async/await and Promises. We cannot "fix" nextTick without breaking the whole ecosystem. nextTick usage should be discouraged and we should try to incrementally move to this new primitive. Refs: nodejs#51156 Refs: nodejs#51280 Refs: nodejs#51114 Refs: nodejs#51070 Refs: nodejs/undici#2497 PR-URL: nodejs#51471
1 parent 94f824a commit 71086a4

File tree

4 files changed

+122
-28
lines changed

4 files changed

+122
-28
lines changed

doc/api/process.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,74 @@ const process = require('node:process');
12191219
process.debugPort = 5858;
12201220
```
12211221

1222+
## `process.deferTick(callback[, ...args])`
1223+
1224+
<!-- YAML
1225+
added: REPLACEME
1226+
-->
1227+
1228+
* `callback` {Function}
1229+
* `...args` {any} Additional arguments to pass when invoking the `callback`
1230+
1231+
`process.deferTick()` adds `callback` to the "defer tick queue". This queue is
1232+
fully drained after the current operation on the JavaScript stack runs to
1233+
completion and before the event loop is allowed to continue. It's possible to
1234+
create an infinite loop if one were to recursively call `process.deferTick()`.
1235+
See the [Event Loop][] guide for more background.
1236+
1237+
Unlike `process.nextTick`, `process.deferTick()` will run after the "next tick
1238+
queue" and the microtask queue has been fully drained as to avoid Zalgo when
1239+
combinding traditional node asynchronous code with Promises.
1240+
1241+
Consider the following example:
1242+
1243+
```js
1244+
// uncaughtException!
1245+
setImmediate(async () => {
1246+
const e = await new Promise((resolve) => {
1247+
const e = new EventEmitter();
1248+
resolve(e);
1249+
process.nextTick(() => {
1250+
e.emit('error', new Error('process.nextTick'));
1251+
});
1252+
});
1253+
e.on('error', () => {}); // e.emit executes before we reach this...
1254+
});
1255+
1256+
// uncaughtException!
1257+
setImmediate(async () => {
1258+
const e = await new Promise((resolve) => {
1259+
const e = new EventEmitter();
1260+
resolve(e);
1261+
queueMicrotask(() => {
1262+
e.emit('error', new Error('queueMicrotask'));
1263+
});
1264+
});
1265+
e.on('error', () => {}); // e.emit executes before we reach this...
1266+
});
1267+
```
1268+
1269+
In both of these cases the user will encounter an
1270+
`uncaughtException` error since the inner task
1271+
will execute before control is returned to the
1272+
caller of `await`. In order to fix this one should
1273+
use `process.deferTick` which will execute in the
1274+
expected order:
1275+
1276+
```js
1277+
// OK!
1278+
setImmediate(async () => {
1279+
const e = await new Promise((resolve) => {
1280+
const e = new EventEmitter();
1281+
resolve(e);
1282+
process.deferTick(() => {
1283+
e.emit('error', new Error('process.deferTick'));
1284+
});
1285+
});
1286+
e.on('error', () => {}); // e.emit executes *after* we reach this.
1287+
});
1288+
```
1289+
12221290
## `process.disconnect()`
12231291

12241292
<!-- YAML

lib/internal/bootstrap/node.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,8 +303,9 @@ process.emitWarning = emitWarning;
303303
// bootstrap to make sure that any operation done before this are synchronous.
304304
// If any ticks or timers are scheduled before this they are unlikely to work.
305305
{
306-
const { nextTick, runNextTicks } = setupTaskQueue();
306+
const { nextTick, runNextTicks, deferTick } = setupTaskQueue();
307307
process.nextTick = nextTick;
308+
process.deferTick = deferTick;
308309
// Used to emulate a tick manually in the JS land.
309310
// A better name for this function would be `runNextTicks` but
310311
// it has been exposed to the process object so we keep this legacy name

lib/internal/process/task_queues.js

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict';
22

33
const {
4-
Array,
54
FunctionPrototypeBind,
65
} = primordials;
76

@@ -44,21 +43,14 @@ const { AsyncResource } = require('async_hooks');
4443
// *Must* match Environment::TickInfo::Fields in src/env.h.
4544
const kHasTickScheduled = 0;
4645

47-
function hasTickScheduled() {
48-
return tickInfo[kHasTickScheduled] === 1;
49-
}
50-
51-
function setHasTickScheduled(value) {
52-
tickInfo[kHasTickScheduled] = value ? 1 : 0;
53-
}
54-
55-
const queue = new FixedQueue();
46+
let queue = new FixedQueue();
47+
let deferQueue = new FixedQueue();
5648

5749
// Should be in sync with RunNextTicksNative in node_task_queue.cc
5850
function runNextTicks() {
59-
if (!hasTickScheduled() && !hasRejectionToWarn())
51+
if (tickInfo[kHasTickScheduled] === 0 && !hasRejectionToWarn())
6052
runMicrotasks();
61-
if (!hasTickScheduled() && !hasRejectionToWarn())
53+
if (tickInfo[kHasTickScheduled] === 0 && !hasRejectionToWarn())
6254
return;
6355

6456
processTicksAndRejections();
@@ -93,46 +85,63 @@ function processTicksAndRejections() {
9385
emitAfter(asyncId);
9486
}
9587
runMicrotasks();
88+
89+
const tmp = queue;
90+
queue = deferQueue;
91+
deferQueue = tmp;
9692
} while (!queue.isEmpty() || processPromiseRejections());
97-
setHasTickScheduled(false);
93+
tickInfo[kHasTickScheduled] = 0;
9894
setHasRejectionToWarn(false);
9995
}
10096

10197
// `nextTick()` will not enqueue any callback when the process is about to
10298
// exit since the callback would not have a chance to be executed.
103-
function nextTick(callback) {
99+
function nextTick(callback, ...args) {
104100
validateFunction(callback, 'callback');
105101

106102
if (process._exiting)
107103
return;
108104

109-
let args;
110-
switch (arguments.length) {
111-
case 1: break;
112-
case 2: args = [arguments[1]]; break;
113-
case 3: args = [arguments[1], arguments[2]]; break;
114-
case 4: args = [arguments[1], arguments[2], arguments[3]]; break;
115-
default:
116-
args = new Array(arguments.length - 1);
117-
for (let i = 1; i < arguments.length; i++)
118-
args[i - 1] = arguments[i];
105+
if (tickInfo[kHasTickScheduled] === 0) {
106+
tickInfo[kHasTickScheduled] = 1;
119107
}
120108

121-
if (queue.isEmpty())
122-
setHasTickScheduled(true);
123109
const asyncId = newAsyncId();
124110
const triggerAsyncId = getDefaultTriggerAsyncId();
125111
const tickObject = {
126112
[async_id_symbol]: asyncId,
127113
[trigger_async_id_symbol]: triggerAsyncId,
128114
callback,
129-
args,
115+
args: args.length > 0 ? args : undefined,
130116
};
131117
if (initHooksExist())
132118
emitInit(asyncId, 'TickObject', triggerAsyncId, tickObject);
133119
queue.push(tickObject);
134120
}
135121

122+
function deferTick(callback, ...args) {
123+
validateFunction(callback, 'callback');
124+
125+
if (process._exiting)
126+
return;
127+
128+
if (tickInfo[kHasTickScheduled] === 0) {
129+
tickInfo[kHasTickScheduled] = 1;
130+
}
131+
132+
const asyncId = newAsyncId();
133+
const triggerAsyncId = getDefaultTriggerAsyncId();
134+
const tickObject = {
135+
[async_id_symbol]: asyncId,
136+
[trigger_async_id_symbol]: triggerAsyncId,
137+
callback,
138+
args: args.length > 0 ? args : undefined,
139+
};
140+
if (initHooksExist())
141+
emitInit(asyncId, 'TickObject', triggerAsyncId, tickObject);
142+
deferQueue.push(tickObject);
143+
}
144+
136145
function runMicrotask() {
137146
this.runInAsyncScope(() => {
138147
const callback = this.callback;
@@ -166,6 +175,7 @@ module.exports = {
166175
setTickCallback(processTicksAndRejections);
167176
return {
168177
nextTick,
178+
deferTick,
169179
runNextTicks,
170180
};
171181
},

test/async-hooks/test-defertick.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const { EventEmitter } = require('events');
5+
6+
setImmediate(async () => {
7+
const e = await new Promise((resolve) => {
8+
const e = new EventEmitter();
9+
resolve(e);
10+
process.deferTick(common.mustCall(() => {
11+
e.emit('error', new Error('kaboom'));
12+
}));
13+
});
14+
e.on('error', common.mustCall());
15+
});

0 commit comments

Comments
 (0)