Skip to content

Add uninstall() which will remove hooks from the environment #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions source-map-support.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
145 changes: 100 additions & 45 deletions source-map-support.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ function dynamicRequire(mod, request) {
return mod.require(request);
}

/**
* @typedef {{
* enabled: boolean;
* originalValue: any;
* installedValue: any;
* }} HookState
* Used for installing and uninstalling hooks
*/

// Increment this if the format of sharedData changes in a breaking way.
var sharedDataVersion = 1;

Expand Down Expand Up @@ -63,8 +72,11 @@ function initializeSharedData(defaults) {
var sharedData = initializeSharedData({

// Only install once if called multiple times
errorFormatterInstalled: false,
uncaughtShimInstalled: false,
// Remember how the environment looked before installation so we can restore if able
/** @type {HookState} */
errorPrepareStackTraceHook: undefined,
/** @type {HookState} */
processEmitHook: undefined,

// If true, the caches are reset before a stack trace formatting operation
emptyCacheBetweenOperations: false,
Expand Down Expand Up @@ -483,38 +495,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 (sharedData.emptyCacheBetweenOperations) {
sharedData.fileContentsCache = {};
sharedData.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 (sharedData.emptyCacheBetweenOperations) {
sharedData.fileContentsCache = {};
sharedData.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
Expand Down Expand Up @@ -571,19 +590,26 @@ function printFatalErrorUponExit (error) {
}

function shimEmitUncaughtException () {
var origEmit = process.emit;
const originalValue = process.emit;
var hook = sharedData.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 = sharedData.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;
};
Expand Down Expand Up @@ -650,13 +676,19 @@ exports.install = function(options) {
options.emptyCacheBetweenOperations : false;
}


// Install the error reformatter
if (!sharedData.errorFormatterInstalled) {
sharedData.errorFormatterInstalled = true;
Error.prepareStackTrace = prepareStackTrace;
if (!sharedData.errorPrepareStackTraceHook) {
const originalValue = Error.prepareStackTrace;
sharedData.errorPrepareStackTraceHook = {
enabled: true,
originalValue,
installedValue: undefined
};
Error.prepareStackTrace = sharedData.errorPrepareStackTraceHook.installedValue = createPrepareStackTrace(sharedData.errorPrepareStackTraceHook);
}

if (!sharedData.uncaughtShimInstalled) {
if (!sharedData.processEmitHook) {
var installHandler = 'handleUncaughtExceptions' in options ?
options.handleUncaughtExceptions : true;

Expand All @@ -679,12 +711,35 @@ exports.install = function(options) {
// generated JavaScript code will be shown above the stack trace instead of
// the original source code.
if (installHandler && hasGlobalProcessEventEmitter()) {
sharedData.uncaughtShimInstalled = true;
shimEmitUncaughtException();
}
}
};

exports.uninstall = function() {
if(sharedData.processEmitHook) {
// Disable behavior
sharedData.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 === sharedData.processEmitHook.installedValue) {
process.emit = sharedData.processEmitHook.originalValue;
}
sharedData.processEmitHook = undefined;
}
if(sharedData.errorPrepareStackTraceHook) {
// Disable behavior
sharedData.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 === sharedData.errorPrepareStackTraceHook.installedValue || typeof sharedData.errorPrepareStackTraceHook.originalValue !== 'function') {
Error.prepareStackTrace = sharedData.errorPrepareStackTraceHook.originalValue;
}
sharedData.errorPrepareStackTraceHook = undefined;
}
}

exports.resetRetrieveHandlers = function() {
sharedData.retrieveFileHandlers.length = 0;
sharedData.retrieveMapHandlers.length = 0;
Expand Down
102 changes: 98 additions & 4 deletions test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -680,3 +703,74 @@ it('supports multiple instances', function(done) {
/^ at foo \((?:.*[/\\])?.original2\.js:1:1\)$/
]);
});

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);
});
});