Skip to content

Commit 33161bb

Browse files
committed
Add uninstall() which will remove hooks from the environment
1 parent 2e086aa commit 33161bb

File tree

3 files changed

+201
-49
lines changed

3 files changed

+201
-49
lines changed

source-map-support.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,8 @@ export function resetRetrieveHandlers(): void;
5050
* @param options Can be used to e.g. disable uncaughtException handler.
5151
*/
5252
export function install(options?: Options): void;
53+
54+
/**
55+
* Uninstall SourceMap support.
56+
*/
57+
export function uninstall(): void;

source-map-support.js

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,18 @@ function dynamicRequire(mod, request) {
2424
}
2525

2626
// Only install once if called multiple times
27-
var errorFormatterInstalled = false;
28-
var uncaughtShimInstalled = false;
27+
// Remember how the environment looked before installation so we can restore if able
28+
/** @type {HookState} */
29+
var errorPrepareStackTraceHook;
30+
/** @type {HookState} */
31+
var processEmitHook;
32+
/**
33+
* @typedef {{
34+
* enabled: boolean;
35+
* originalValue: any;
36+
* installedValue: any;
37+
* }} HookState
38+
*/
2939

3040
// If true, the caches are reset before a stack trace formatting operation
3141
var emptyCacheBetweenOperations = false;
@@ -431,38 +441,45 @@ try {
431441

432442
const ErrorPrototypeToString = (err) =>Error.prototype.toString.call(err);
433443

434-
// This function is part of the V8 stack trace API, for more info see:
435-
// https://v8.dev/docs/stack-trace-api
436-
function prepareStackTrace(error, stack) {
437-
if (emptyCacheBetweenOperations) {
438-
fileContentsCache = {};
439-
sourceMapCache = {};
440-
}
441-
442-
// node gives its own errors special treatment. Mimic that behavior
443-
// https://github.com/nodejs/node/blob/3cbaabc4622df1b4009b9d026a1a970bdbae6e89/lib/internal/errors.js#L118-L128
444-
// https://github.com/nodejs/node/pull/39182
445-
var errorString;
446-
if (kIsNodeError) {
447-
if(kIsNodeError in error) {
448-
errorString = `${error.name} [${error.code}]: ${error.message}`;
444+
/** @param {HookState} hookState */
445+
function createPrepareStackTrace(hookState) {
446+
return prepareStackTrace;
447+
448+
// This function is part of the V8 stack trace API, for more info see:
449+
// https://v8.dev/docs/stack-trace-api
450+
function prepareStackTrace(error, stack) {
451+
if(!hookState.enabled) return hookState.originalValue.apply(this, arguments);
452+
453+
if (emptyCacheBetweenOperations) {
454+
fileContentsCache = {};
455+
sourceMapCache = {};
456+
}
457+
458+
// node gives its own errors special treatment. Mimic that behavior
459+
// https://github.com/nodejs/node/blob/3cbaabc4622df1b4009b9d026a1a970bdbae6e89/lib/internal/errors.js#L118-L128
460+
// https://github.com/nodejs/node/pull/39182
461+
var errorString;
462+
if (kIsNodeError) {
463+
if(kIsNodeError in error) {
464+
errorString = `${error.name} [${error.code}]: ${error.message}`;
465+
} else {
466+
errorString = ErrorPrototypeToString(error);
467+
}
449468
} else {
450-
errorString = ErrorPrototypeToString(error);
469+
var name = error.name || 'Error';
470+
var message = error.message || '';
471+
errorString = name + ": " + message;
451472
}
452-
} else {
453-
var name = error.name || 'Error';
454-
var message = error.message || '';
455-
errorString = name + ": " + message;
456-
}
457473

458-
var state = { nextPosition: null, curPosition: null };
459-
var processedStack = [];
460-
for (var i = stack.length - 1; i >= 0; i--) {
461-
processedStack.push('\n at ' + wrapCallSite(stack[i], state));
462-
state.nextPosition = state.curPosition;
474+
var state = { nextPosition: null, curPosition: null };
475+
var processedStack = [];
476+
for (var i = stack.length - 1; i >= 0; i--) {
477+
processedStack.push('\n at ' + wrapCallSite(stack[i], state));
478+
state.nextPosition = state.curPosition;
479+
}
480+
state.curPosition = state.nextPosition = null;
481+
return errorString + processedStack.reverse().join('');
463482
}
464-
state.curPosition = state.nextPosition = null;
465-
return errorString + processedStack.reverse().join('');
466483
}
467484

468485
// Generate position and snippet of original source with pointer
@@ -519,19 +536,26 @@ function printFatalErrorUponExit (error) {
519536
}
520537

521538
function shimEmitUncaughtException () {
522-
var origEmit = process.emit;
539+
const originalValue = process.emit;
540+
var hook = processEmitHook = {
541+
enabled: true,
542+
originalValue,
543+
installedValue: undefined
544+
};
523545
var isTerminatingDueToFatalException = false;
524546
var fatalException;
525547

526-
process.emit = function (type) {
527-
const hadListeners = origEmit.apply(this, arguments);
528-
if (type === 'uncaughtException' && !hadListeners) {
529-
isTerminatingDueToFatalException = true;
530-
fatalException = arguments[1];
531-
process.exit(1);
532-
}
533-
if (type === 'exit' && isTerminatingDueToFatalException) {
534-
printFatalErrorUponExit(fatalException);
548+
process.emit = processEmitHook.installedValue = function (type) {
549+
const hadListeners = originalValue.apply(this, arguments);
550+
if(hook.enabled) {
551+
if (type === 'uncaughtException' && !hadListeners) {
552+
isTerminatingDueToFatalException = true;
553+
fatalException = arguments[1];
554+
process.exit(1);
555+
}
556+
if (type === 'exit' && isTerminatingDueToFatalException) {
557+
printFatalErrorUponExit(fatalException);
558+
}
535559
}
536560
return hadListeners;
537561
};
@@ -598,13 +622,19 @@ exports.install = function(options) {
598622
options.emptyCacheBetweenOperations : false;
599623
}
600624

625+
601626
// Install the error reformatter
602-
if (!errorFormatterInstalled) {
603-
errorFormatterInstalled = true;
604-
Error.prepareStackTrace = prepareStackTrace;
627+
if (!errorPrepareStackTraceHook) {
628+
const originalValue = Error.prepareStackTrace;
629+
errorPrepareStackTraceHook = {
630+
enabled: true,
631+
originalValue,
632+
installedValue: undefined
633+
};
634+
Error.prepareStackTrace = errorPrepareStackTraceHook.installedValue = createPrepareStackTrace(errorPrepareStackTraceHook);
605635
}
606636

607-
if (!uncaughtShimInstalled) {
637+
if (!processEmitHook) {
608638
var installHandler = 'handleUncaughtExceptions' in options ?
609639
options.handleUncaughtExceptions : true;
610640

@@ -627,12 +657,35 @@ exports.install = function(options) {
627657
// generated JavaScript code will be shown above the stack trace instead of
628658
// the original source code.
629659
if (installHandler && hasGlobalProcessEventEmitter()) {
630-
uncaughtShimInstalled = true;
631660
shimEmitUncaughtException();
632661
}
633662
}
634663
};
635664

665+
exports.uninstall = function() {
666+
if(processEmitHook) {
667+
// Disable behavior
668+
processEmitHook.enabled = false;
669+
// If possible, remove our hook function. May not be possible if subsequent third-party hooks have wrapped around us.
670+
if(process.emit === processEmitHook.installedValue) {
671+
process.emit = processEmitHook.originalValue;
672+
}
673+
processEmitHook = undefined;
674+
}
675+
if(errorPrepareStackTraceHook) {
676+
// Disable behavior
677+
errorPrepareStackTraceHook.enabled = false;
678+
// If possible or necessary, remove our hook function.
679+
// In vanilla environments, prepareStackTrace is `undefined`.
680+
// We cannot delegate to `undefined` the way we can to a function w/`.apply()`; our only option is to remove the function.
681+
// If we are the *first* hook installed, and another was installed on top of us, we have no choice but to remove both.
682+
if(Error.prepareStackTrace === errorPrepareStackTraceHook.installedValue || typeof errorPrepareStackTraceHook.originalValue !== 'function') {
683+
Error.prepareStackTrace = errorPrepareStackTraceHook.originalValue;
684+
}
685+
errorPrepareStackTraceHook = undefined;
686+
}
687+
}
688+
636689
exports.resetRetrieveHandlers = function() {
637690
retrieveFileHandlers.length = 0;
638691
retrieveMapHandlers.length = 0;

test.js

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
require('./source-map-support').install({
2-
emptyCacheBetweenOperations: true // Needed to be able to test for failure
3-
});
1+
// Note: some tests rely on side-effects from prior tests.
2+
// You may not get meaningful results running a subset of tests.
43

4+
const priorErrorPrepareStackTrace = Error.prepareStackTrace;
5+
const priorProcessEmit = process.emit;
6+
const underTest = require('./source-map-support');
57
var SourceMapGenerator = require('source-map').SourceMapGenerator;
68
var child_process = require('child_process');
79
var assert = require('assert');
@@ -136,14 +138,35 @@ function compareStdout(done, sourceMap, source, expected) {
136138
});
137139
}
138140

141+
it('normal throw without source-map-support installed', normalThrowWithoutSourceMapSupportInstalled);
142+
139143
it('normal throw', function() {
144+
installSms();
145+
normalThrow();
146+
});
147+
148+
function installSms() {
149+
underTest.install({
150+
emptyCacheBetweenOperations: true // Needed to be able to test for failure
151+
});
152+
}
153+
154+
function normalThrow() {
140155
compareStackTrace(createMultiLineSourceMap(), [
141156
'throw new Error("test");'
142157
], [
143158
'Error: test',
144159
/^ at Object\.exports\.test \((?:.*[/\\])?line1\.js:1001:101\)$/
145160
]);
146-
});
161+
}
162+
function normalThrowWithoutSourceMapSupportInstalled() {
163+
compareStackTrace(createMultiLineSourceMap(), [
164+
'throw new Error("test");'
165+
], [
166+
'Error: test',
167+
/^ at Object\.exports\.test \((?:.*[/\\])?\.generated\.js:1:34\)$/
168+
]);
169+
}
147170

148171
/* The following test duplicates some of the code in
149172
* `normal throw` but triggers file read failure.
@@ -638,3 +661,74 @@ it('normal console.trace', function(done) {
638661
/^ at Object\.<anonymous> \((?:.*[/\\])?line2\.js:1002:102\)$/
639662
]);
640663
});
664+
665+
describe('uninstall', function() {
666+
this.beforeEach(function() {
667+
underTest.uninstall();
668+
process.emit = priorProcessEmit;
669+
Error.prepareStackTrace = priorErrorPrepareStackTrace;
670+
});
671+
672+
it('uninstall removes hooks and source-mapping behavior', function() {
673+
assert.strictEqual(Error.prepareStackTrace, priorErrorPrepareStackTrace);
674+
assert.strictEqual(process.emit, priorProcessEmit);
675+
normalThrowWithoutSourceMapSupportInstalled();
676+
});
677+
678+
it('install re-adds hooks', function() {
679+
installSms();
680+
normalThrow();
681+
});
682+
683+
it('uninstall removes prepareStackTrace even in presence of third-party hooks if none were installed before us', function() {
684+
installSms();
685+
const wrappedPrepareStackTrace = Error.prepareStackTrace;
686+
let pstInvocations = 0;
687+
function thirdPartyPrepareStackTraceHook() {
688+
pstInvocations++;
689+
return wrappedPrepareStackTrace.apply(this, arguments);
690+
}
691+
Error.prepareStackTrace = thirdPartyPrepareStackTraceHook;
692+
underTest.uninstall();
693+
assert.strictEqual(Error.prepareStackTrace, undefined);
694+
assert(pstInvocations === 0);
695+
});
696+
697+
it('uninstall preserves third-party prepareStackTrace hooks if one was installed before us', function() {
698+
let beforeInvocations = 0;
699+
function thirdPartyPrepareStackTraceHookInstalledBefore() {
700+
beforeInvocations++;
701+
return 'foo';
702+
}
703+
Error.prepareStackTrace = thirdPartyPrepareStackTraceHookInstalledBefore;
704+
installSms();
705+
const wrappedPrepareStackTrace = Error.prepareStackTrace;
706+
let afterInvocations = 0;
707+
function thirdPartyPrepareStackTraceHookInstalledAfter() {
708+
afterInvocations++;
709+
return wrappedPrepareStackTrace.apply(this, arguments);
710+
}
711+
Error.prepareStackTrace = thirdPartyPrepareStackTraceHookInstalledAfter;
712+
underTest.uninstall();
713+
assert.strictEqual(Error.prepareStackTrace, thirdPartyPrepareStackTraceHookInstalledAfter);
714+
assert.strictEqual(new Error().stack, 'foo');
715+
assert.strictEqual(beforeInvocations, 1);
716+
assert.strictEqual(afterInvocations, 1);
717+
});
718+
719+
it('uninstall preserves third-party process.emit hooks installed after us', function() {
720+
installSms();
721+
const wrappedProcessEmit = process.emit;
722+
let peInvocations = 0;
723+
function thirdPartyProcessEmit() {
724+
peInvocations++;
725+
return wrappedProcessEmit.apply(this, arguments);
726+
}
727+
process.emit = thirdPartyProcessEmit;
728+
underTest.uninstall();
729+
assert.strictEqual(process.emit, thirdPartyProcessEmit);
730+
normalThrowWithoutSourceMapSupportInstalled();
731+
process.emit('foo');
732+
assert(peInvocations >= 1);
733+
});
734+
});

0 commit comments

Comments
 (0)