From 33161bbe222d6f048315ae7e7b93db9f6fcde782 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 3 Oct 2021 23:43:50 -0400 Subject: [PATCH] Add `uninstall()` which will remove hooks from the environment --- source-map-support.d.ts | 5 ++ source-map-support.js | 143 +++++++++++++++++++++++++++------------- test.js | 102 ++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 49 deletions(-) diff --git a/source-map-support.d.ts b/source-map-support.d.ts index d91e5b6..701e917 100755 --- a/source-map-support.d.ts +++ b/source-map-support.d.ts @@ -50,3 +50,8 @@ export function resetRetrieveHandlers(): void; * @param options Can be used to e.g. disable uncaughtException handler. */ export function install(options?: Options): void; + +/** + * Uninstall SourceMap support. + */ +export function uninstall(): void; diff --git a/source-map-support.js b/source-map-support.js index e2568d7..a555e79 100644 --- a/source-map-support.js +++ b/source-map-support.js @@ -24,8 +24,18 @@ function dynamicRequire(mod, request) { } // Only install once if called multiple times -var errorFormatterInstalled = false; -var uncaughtShimInstalled = false; +// Remember how the environment looked before installation so we can restore if able +/** @type {HookState} */ +var errorPrepareStackTraceHook; +/** @type {HookState} */ +var processEmitHook; +/** + * @typedef {{ + * enabled: boolean; + * originalValue: any; + * installedValue: any; + * }} HookState + */ // If true, the caches are reset before a stack trace formatting operation var emptyCacheBetweenOperations = false; @@ -431,38 +441,45 @@ try { const ErrorPrototypeToString = (err) =>Error.prototype.toString.call(err); -// This function is part of the V8 stack trace API, for more info see: -// https://v8.dev/docs/stack-trace-api -function prepareStackTrace(error, stack) { - if (emptyCacheBetweenOperations) { - fileContentsCache = {}; - sourceMapCache = {}; - } - - // node gives its own errors special treatment. Mimic that behavior - // https://github.com/nodejs/node/blob/3cbaabc4622df1b4009b9d026a1a970bdbae6e89/lib/internal/errors.js#L118-L128 - // https://github.com/nodejs/node/pull/39182 - var errorString; - if (kIsNodeError) { - if(kIsNodeError in error) { - errorString = `${error.name} [${error.code}]: ${error.message}`; +/** @param {HookState} hookState */ +function createPrepareStackTrace(hookState) { + return prepareStackTrace; + + // This function is part of the V8 stack trace API, for more info see: + // https://v8.dev/docs/stack-trace-api + function prepareStackTrace(error, stack) { + if(!hookState.enabled) return hookState.originalValue.apply(this, arguments); + + if (emptyCacheBetweenOperations) { + fileContentsCache = {}; + sourceMapCache = {}; + } + + // node gives its own errors special treatment. Mimic that behavior + // https://github.com/nodejs/node/blob/3cbaabc4622df1b4009b9d026a1a970bdbae6e89/lib/internal/errors.js#L118-L128 + // https://github.com/nodejs/node/pull/39182 + var errorString; + if (kIsNodeError) { + if(kIsNodeError in error) { + errorString = `${error.name} [${error.code}]: ${error.message}`; + } else { + errorString = ErrorPrototypeToString(error); + } } else { - errorString = ErrorPrototypeToString(error); + var name = error.name || 'Error'; + var message = error.message || ''; + errorString = name + ": " + message; } - } else { - var name = error.name || 'Error'; - var message = error.message || ''; - errorString = name + ": " + message; - } - var state = { nextPosition: null, curPosition: null }; - var processedStack = []; - for (var i = stack.length - 1; i >= 0; i--) { - processedStack.push('\n at ' + wrapCallSite(stack[i], state)); - state.nextPosition = state.curPosition; + var state = { nextPosition: null, curPosition: null }; + var processedStack = []; + for (var i = stack.length - 1; i >= 0; i--) { + processedStack.push('\n at ' + wrapCallSite(stack[i], state)); + state.nextPosition = state.curPosition; + } + state.curPosition = state.nextPosition = null; + return errorString + processedStack.reverse().join(''); } - state.curPosition = state.nextPosition = null; - return errorString + processedStack.reverse().join(''); } // Generate position and snippet of original source with pointer @@ -519,19 +536,26 @@ function printFatalErrorUponExit (error) { } function shimEmitUncaughtException () { - var origEmit = process.emit; + const originalValue = process.emit; + var hook = processEmitHook = { + enabled: true, + originalValue, + installedValue: undefined + }; var isTerminatingDueToFatalException = false; var fatalException; - process.emit = function (type) { - const hadListeners = origEmit.apply(this, arguments); - if (type === 'uncaughtException' && !hadListeners) { - isTerminatingDueToFatalException = true; - fatalException = arguments[1]; - process.exit(1); - } - if (type === 'exit' && isTerminatingDueToFatalException) { - printFatalErrorUponExit(fatalException); + process.emit = processEmitHook.installedValue = function (type) { + const hadListeners = originalValue.apply(this, arguments); + if(hook.enabled) { + if (type === 'uncaughtException' && !hadListeners) { + isTerminatingDueToFatalException = true; + fatalException = arguments[1]; + process.exit(1); + } + if (type === 'exit' && isTerminatingDueToFatalException) { + printFatalErrorUponExit(fatalException); + } } return hadListeners; }; @@ -598,13 +622,19 @@ exports.install = function(options) { options.emptyCacheBetweenOperations : false; } + // Install the error reformatter - if (!errorFormatterInstalled) { - errorFormatterInstalled = true; - Error.prepareStackTrace = prepareStackTrace; + if (!errorPrepareStackTraceHook) { + const originalValue = Error.prepareStackTrace; + errorPrepareStackTraceHook = { + enabled: true, + originalValue, + installedValue: undefined + }; + Error.prepareStackTrace = errorPrepareStackTraceHook.installedValue = createPrepareStackTrace(errorPrepareStackTraceHook); } - if (!uncaughtShimInstalled) { + if (!processEmitHook) { var installHandler = 'handleUncaughtExceptions' in options ? options.handleUncaughtExceptions : true; @@ -627,12 +657,35 @@ exports.install = function(options) { // generated JavaScript code will be shown above the stack trace instead of // the original source code. if (installHandler && hasGlobalProcessEventEmitter()) { - uncaughtShimInstalled = true; shimEmitUncaughtException(); } } }; +exports.uninstall = function() { + if(processEmitHook) { + // Disable behavior + processEmitHook.enabled = false; + // If possible, remove our hook function. May not be possible if subsequent third-party hooks have wrapped around us. + if(process.emit === processEmitHook.installedValue) { + process.emit = processEmitHook.originalValue; + } + processEmitHook = undefined; + } + if(errorPrepareStackTraceHook) { + // Disable behavior + errorPrepareStackTraceHook.enabled = false; + // If possible or necessary, remove our hook function. + // In vanilla environments, prepareStackTrace is `undefined`. + // We cannot delegate to `undefined` the way we can to a function w/`.apply()`; our only option is to remove the function. + // If we are the *first* hook installed, and another was installed on top of us, we have no choice but to remove both. + if(Error.prepareStackTrace === errorPrepareStackTraceHook.installedValue || typeof errorPrepareStackTraceHook.originalValue !== 'function') { + Error.prepareStackTrace = errorPrepareStackTraceHook.originalValue; + } + errorPrepareStackTraceHook = undefined; + } +} + exports.resetRetrieveHandlers = function() { retrieveFileHandlers.length = 0; retrieveMapHandlers.length = 0; diff --git a/test.js b/test.js index ff4a6d7..6432e72 100644 --- a/test.js +++ b/test.js @@ -1,7 +1,9 @@ -require('./source-map-support').install({ - emptyCacheBetweenOperations: true // Needed to be able to test for failure -}); +// Note: some tests rely on side-effects from prior tests. +// You may not get meaningful results running a subset of tests. +const priorErrorPrepareStackTrace = Error.prepareStackTrace; +const priorProcessEmit = process.emit; +const underTest = require('./source-map-support'); var SourceMapGenerator = require('source-map').SourceMapGenerator; var child_process = require('child_process'); var assert = require('assert'); @@ -136,14 +138,35 @@ function compareStdout(done, sourceMap, source, expected) { }); } +it('normal throw without source-map-support installed', normalThrowWithoutSourceMapSupportInstalled); + it('normal throw', function() { + installSms(); + normalThrow(); +}); + +function installSms() { + underTest.install({ + emptyCacheBetweenOperations: true // Needed to be able to test for failure + }); +} + +function normalThrow() { compareStackTrace(createMultiLineSourceMap(), [ 'throw new Error("test");' ], [ 'Error: test', /^ at Object\.exports\.test \((?:.*[/\\])?line1\.js:1001:101\)$/ ]); -}); +} +function normalThrowWithoutSourceMapSupportInstalled() { + compareStackTrace(createMultiLineSourceMap(), [ + 'throw new Error("test");' + ], [ + 'Error: test', + /^ at Object\.exports\.test \((?:.*[/\\])?\.generated\.js:1:34\)$/ + ]); +} /* The following test duplicates some of the code in * `normal throw` but triggers file read failure. @@ -638,3 +661,74 @@ it('normal console.trace', function(done) { /^ at Object\. \((?:.*[/\\])?line2\.js:1002:102\)$/ ]); }); + +describe('uninstall', function() { + this.beforeEach(function() { + underTest.uninstall(); + process.emit = priorProcessEmit; + Error.prepareStackTrace = priorErrorPrepareStackTrace; + }); + + it('uninstall removes hooks and source-mapping behavior', function() { + assert.strictEqual(Error.prepareStackTrace, priorErrorPrepareStackTrace); + assert.strictEqual(process.emit, priorProcessEmit); + normalThrowWithoutSourceMapSupportInstalled(); + }); + + it('install re-adds hooks', function() { + installSms(); + normalThrow(); + }); + + it('uninstall removes prepareStackTrace even in presence of third-party hooks if none were installed before us', function() { + installSms(); + const wrappedPrepareStackTrace = Error.prepareStackTrace; + let pstInvocations = 0; + function thirdPartyPrepareStackTraceHook() { + pstInvocations++; + return wrappedPrepareStackTrace.apply(this, arguments); + } + Error.prepareStackTrace = thirdPartyPrepareStackTraceHook; + underTest.uninstall(); + assert.strictEqual(Error.prepareStackTrace, undefined); + assert(pstInvocations === 0); + }); + + it('uninstall preserves third-party prepareStackTrace hooks if one was installed before us', function() { + let beforeInvocations = 0; + function thirdPartyPrepareStackTraceHookInstalledBefore() { + beforeInvocations++; + return 'foo'; + } + Error.prepareStackTrace = thirdPartyPrepareStackTraceHookInstalledBefore; + installSms(); + const wrappedPrepareStackTrace = Error.prepareStackTrace; + let afterInvocations = 0; + function thirdPartyPrepareStackTraceHookInstalledAfter() { + afterInvocations++; + return wrappedPrepareStackTrace.apply(this, arguments); + } + Error.prepareStackTrace = thirdPartyPrepareStackTraceHookInstalledAfter; + underTest.uninstall(); + assert.strictEqual(Error.prepareStackTrace, thirdPartyPrepareStackTraceHookInstalledAfter); + assert.strictEqual(new Error().stack, 'foo'); + assert.strictEqual(beforeInvocations, 1); + assert.strictEqual(afterInvocations, 1); + }); + + it('uninstall preserves third-party process.emit hooks installed after us', function() { + installSms(); + const wrappedProcessEmit = process.emit; + let peInvocations = 0; + function thirdPartyProcessEmit() { + peInvocations++; + return wrappedProcessEmit.apply(this, arguments); + } + process.emit = thirdPartyProcessEmit; + underTest.uninstall(); + assert.strictEqual(process.emit, thirdPartyProcessEmit); + normalThrowWithoutSourceMapSupportInstalled(); + process.emit('foo'); + assert(peInvocations >= 1); + }); +}); \ No newline at end of file