From ae24770c04f2adbc0d19e622cedecf371b4657ba Mon Sep 17 00:00:00 2001 From: batuhan-bas Date: Fri, 7 Aug 2026 23:19:41 +0300 Subject: [PATCH] perf_hooks: fix timerify() inconsistency for rejected thenables timerify() used Promise.prototype.finally() to record a function's duration and histogram entry once its returned thenable settled, regardless of whether it fulfilled or rejected. This meant a timerified async function that throws would still be recorded, while a timerified sync function that throws never reaches the recording step at all, since it throws before returning. Switch to then() with only an onFulfilled handler so a rejected thenable behaves like a synchronous throw: no performance entry and no histogram record, and the rejection still propagates unchanged. Fixes: https://github.com/nodejs/node/issues/42743 --- lib/internal/perf/timerify.js | 16 +++++++++++--- .../test-perf-hooks-timerify-async-error.js | 22 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-perf-hooks-timerify-async-error.js diff --git a/lib/internal/perf/timerify.js b/lib/internal/perf/timerify.js index f9e9b8a39d99..045fd471c1e9 100644 --- a/lib/internal/perf/timerify.js +++ b/lib/internal/perf/timerify.js @@ -52,6 +52,11 @@ function processComplete(name, start, args, histogram) { enqueue(entry); } +function onThenableFulfilled(name, start, args, histogram, value) { + processComplete(name, start, args, histogram); + return value; +} + function timerify(fn, options = kEmptyObject) { validateFunction(fn, 'fn'); @@ -74,10 +79,15 @@ function timerify(fn, options = kEmptyObject) { const result = isConstructorCall ? ReflectConstruct(fn, args, fn) : ReflectApply(fn, this, args); - if (!isConstructorCall && typeof result?.finally === 'function') { - return result.finally( + if (!isConstructorCall && typeof result?.then === 'function') { + // Only record on fulfillment, not rejection, so a function that + // returns a rejected thenable behaves the same as one that throws + // synchronously (neither reaches `processComplete()`). A plain + // `.finally()` would record in both cases, and thenables are only + // required to implement `then()`. + return result.then( FunctionPrototypeBind( - processComplete, + onThenableFulfilled, result, fn.name, start, diff --git a/test/parallel/test-perf-hooks-timerify-async-error.js b/test/parallel/test-perf-hooks-timerify-async-error.js new file mode 100644 index 000000000000..662b1275a31f --- /dev/null +++ b/test/parallel/test-perf-hooks-timerify-async-error.js @@ -0,0 +1,22 @@ +// Test that a timerified function which returns a rejected promise behaves +// the same as one that throws synchronously: no performance timeline entry +// and no histogram record. + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { timerify, PerformanceObserver, createHistogram } = require('perf_hooks'); + +const obs = new PerformanceObserver(common.mustNotCall()); +obs.observe({ entryTypes: ['function'] }); + +const histogram = createHistogram(); +const n = timerify(async () => { + throw new Error('test'); +}, { histogram }); + +assert.rejects(n(), /^Error: test$/).then(common.mustCall(() => { + assert.strictEqual(histogram.count, 0); + obs.disconnect(); +}));