Skip to content

Commit a1347ed

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 a1347ed

File tree

4 files changed

+123
-36
lines changed

4 files changed

+123
-36
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: 38 additions & 35 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();
@@ -76,14 +68,7 @@ function processTicksAndRejections() {
7668
if (tock.args === undefined) {
7769
callback();
7870
} 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-
}
71+
callback(...args);
8772
}
8873
} finally {
8974
if (destroyHooksExist())
@@ -93,46 +78,63 @@ function processTicksAndRejections() {
9378
emitAfter(asyncId);
9479
}
9580
runMicrotasks();
81+
82+
const tmp = queue;
83+
queue = deferQueue;
84+
deferQueue = tmp;
9685
} while (!queue.isEmpty() || processPromiseRejections());
97-
setHasTickScheduled(false);
86+
tickInfo[kHasTickScheduled] = 0;
9887
setHasRejectionToWarn(false);
9988
}
10089

10190
// `nextTick()` will not enqueue any callback when the process is about to
10291
// exit since the callback would not have a chance to be executed.
103-
function nextTick(callback) {
92+
function nextTick(callback, ...args) {
10493
validateFunction(callback, 'callback');
10594

10695
if (process._exiting)
10796
return;
10897

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];
98+
if (tickInfo[kHasTickScheduled] === 0) {
99+
tickInfo[kHasTickScheduled] = 1;
119100
}
120101

121-
if (queue.isEmpty())
122-
setHasTickScheduled(true);
123102
const asyncId = newAsyncId();
124103
const triggerAsyncId = getDefaultTriggerAsyncId();
125104
const tickObject = {
126105
[async_id_symbol]: asyncId,
127106
[trigger_async_id_symbol]: triggerAsyncId,
128107
callback,
129-
args,
108+
args: args.length > 0 ? args : undefined,
130109
};
131110
if (initHooksExist())
132111
emitInit(asyncId, 'TickObject', triggerAsyncId, tickObject);
133112
queue.push(tickObject);
134113
}
135114

115+
function deferTick(callback, ...args) {
116+
validateFunction(callback, 'callback');
117+
118+
if (process._exiting)
119+
return;
120+
121+
if (tickInfo[kHasTickScheduled] === 0) {
122+
tickInfo[kHasTickScheduled] = 1;
123+
}
124+
125+
const asyncId = newAsyncId();
126+
const triggerAsyncId = getDefaultTriggerAsyncId();
127+
const tickObject = {
128+
[async_id_symbol]: asyncId,
129+
[trigger_async_id_symbol]: triggerAsyncId,
130+
callback,
131+
args: args.length > 0 ? args : undefined,
132+
};
133+
if (initHooksExist())
134+
emitInit(asyncId, 'TickObject', triggerAsyncId, tickObject);
135+
deferQueue.push(tickObject);
136+
}
137+
136138
function runMicrotask() {
137139
this.runInAsyncScope(() => {
138140
const callback = this.callback;
@@ -166,6 +168,7 @@ module.exports = {
166168
setTickCallback(processTicksAndRejections);
167169
return {
168170
nextTick,
171+
deferTick,
169172
runNextTicks,
170173
};
171174
},

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)