Skip to content

Commit 9e07efe

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 9e07efe

File tree

4 files changed

+125
-37
lines changed

4 files changed

+125
-37
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: 40 additions & 36 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();
@@ -72,18 +64,12 @@ function processTicksAndRejections() {
7264
emitBefore(asyncId, tock[trigger_async_id_symbol], tock);
7365

7466
try {
67+
const args = tock.args;
7568
const callback = tock.callback;
76-
if (tock.args === undefined) {
69+
if (args === undefined) {
7770
callback();
7871
} else {
79-
const args = tock.args;
80-
switch (args.length) {
81-
case 1: callback(args[0]); break;
82-
case 2: callback(args[0], args[1]); break;
83-
case 3: callback(args[0], args[1], args[2]); break;
84-
case 4: callback(args[0], args[1], args[2], args[3]); break;
85-
default: callback(...args);
86-
}
72+
callback(...args);
8773
}
8874
} finally {
8975
if (destroyHooksExist())
@@ -93,46 +79,63 @@ function processTicksAndRejections() {
9379
emitAfter(asyncId);
9480
}
9581
runMicrotasks();
82+
83+
const tmp = queue;
84+
queue = deferQueue;
85+
deferQueue = tmp;
9686
} while (!queue.isEmpty() || processPromiseRejections());
97-
setHasTickScheduled(false);
87+
tickInfo[kHasTickScheduled] = 0;
9888
setHasRejectionToWarn(false);
9989
}
10090

10191
// `nextTick()` will not enqueue any callback when the process is about to
10292
// exit since the callback would not have a chance to be executed.
103-
function nextTick(callback) {
93+
function nextTick(callback, ...args) {
10494
validateFunction(callback, 'callback');
10595

10696
if (process._exiting)
10797
return;
10898

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];
99+
if (tickInfo[kHasTickScheduled] === 0) {
100+
tickInfo[kHasTickScheduled] = 1;
119101
}
120102

121-
if (queue.isEmpty())
122-
setHasTickScheduled(true);
123103
const asyncId = newAsyncId();
124104
const triggerAsyncId = getDefaultTriggerAsyncId();
125105
const tickObject = {
126106
[async_id_symbol]: asyncId,
127107
[trigger_async_id_symbol]: triggerAsyncId,
128108
callback,
129-
args,
109+
args: args.length > 0 ? args : undefined,
130110
};
131111
if (initHooksExist())
132112
emitInit(asyncId, 'TickObject', triggerAsyncId, tickObject);
133113
queue.push(tickObject);
134114
}
135115

116+
function deferTick(callback, ...args) {
117+
validateFunction(callback, 'callback');
118+
119+
if (process._exiting)
120+
return;
121+
122+
if (tickInfo[kHasTickScheduled] === 0) {
123+
tickInfo[kHasTickScheduled] = 1;
124+
}
125+
126+
const asyncId = newAsyncId();
127+
const triggerAsyncId = getDefaultTriggerAsyncId();
128+
const tickObject = {
129+
[async_id_symbol]: asyncId,
130+
[trigger_async_id_symbol]: triggerAsyncId,
131+
callback,
132+
args: args.length > 0 ? args : undefined,
133+
};
134+
if (initHooksExist())
135+
emitInit(asyncId, 'TickObject', triggerAsyncId, tickObject);
136+
deferQueue.push(tickObject);
137+
}
138+
136139
function runMicrotask() {
137140
this.runInAsyncScope(() => {
138141
const callback = this.callback;
@@ -166,6 +169,7 @@ module.exports = {
166169
setTickCallback(processTicksAndRejections);
167170
return {
168171
nextTick,
172+
deferTick,
169173
runNextTicks,
170174
};
171175
},

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)