diff options
author | Max Hirschhorn <max.hirschhorn@mongodb.com> | 2018-09-18 21:10:36 -0400 |
---|---|---|
committer | Max Hirschhorn <max.hirschhorn@mongodb.com> | 2018-09-18 21:10:36 -0400 |
commit | f43ea7a6bb2a6c44f91506c32004241d3cbb24ae (patch) | |
tree | 1dff1f481a3439045f0de84a242e1ac33f201249 | |
parent | 78112be586c67efa877636d596b194650e90cbed (diff) | |
download | mongo-f43ea7a6bb2a6c44f91506c32004241d3cbb24ae.tar.gz |
SERVER-35154 Propagate JS exceptions through ScopedThread#join().
This makes it so that if the ScopedThread exited due to an uncaught
JavaScript exception, then calling .join() or .returnData() on it throws
a JavaScript exception with the error message and stacktrace intact.
-rw-r--r-- | jstests/core/error2.js | 19 | ||||
-rw-r--r-- | jstests/core/mr_tolerates_js_exception.js | 38 | ||||
-rw-r--r-- | jstests/core/where_tolerates_js_exception.js | 35 | ||||
-rw-r--r-- | jstests/noPassthrough/shell_scoped_thread.js | 103 | ||||
-rw-r--r-- | jstests/parallel/del.js | 111 | ||||
-rw-r--r-- | src/mongo/base/error_codes.err | 1 | ||||
-rw-r--r-- | src/mongo/scripting/SConscript | 1 | ||||
-rw-r--r-- | src/mongo/scripting/jsexception.cpp | 75 | ||||
-rw-r--r-- | src/mongo/scripting/jsexception.h | 63 | ||||
-rw-r--r-- | src/mongo/scripting/mozjs/implscope.cpp | 60 | ||||
-rw-r--r-- | src/mongo/scripting/mozjs/jsthread.cpp | 27 | ||||
-rw-r--r-- | src/mongo/scripting/mozjs/status.cpp | 44 | ||||
-rw-r--r-- | src/mongo/scripting/mozjs/status.h | 1 |
13 files changed, 482 insertions, 96 deletions
diff --git a/jstests/core/error2.js b/jstests/core/error2.js deleted file mode 100644 index d9c2fdf7f63..00000000000 --- a/jstests/core/error2.js +++ /dev/null @@ -1,19 +0,0 @@ -// Test that client gets stack trace on failed invoke -// @tags: [ -// requires_non_retryable_commands, -// ] - -f = db.jstests_error2; - -f.drop(); - -f.save({a: 1}); - -assert.throws(function() { - c = f.find({ - $where: function() { - return a(); - } - }); - c.next(); -}); diff --git a/jstests/core/mr_tolerates_js_exception.js b/jstests/core/mr_tolerates_js_exception.js index 47536a3cc3f..20e96eb47dc 100644 --- a/jstests/core/mr_tolerates_js_exception.js +++ b/jstests/core/mr_tolerates_js_exception.js @@ -1,7 +1,11 @@ -// @tags: [does_not_support_stepdowns] - /** - * Test that the mapReduce command fails gracefully when user-provided JavaScript code throws. + * Test that the mapReduce command fails gracefully when user-provided JavaScript code throws and + * that the user gets back a JavaScript stacktrace. + * + * @tags: [ + * does_not_support_stepdowns, + * requires_scripting, + * ] */ (function() { "use strict"; @@ -20,18 +24,32 @@ emit(this.a, 1); }, reduce: function(key, value) { - throw 42; + (function myFunction() { + throw new Error("Intentionally thrown inside reduce function"); + })(); }, out: {inline: 1} }); assert.commandFailedWithCode(cmdOutput, ErrorCodes.JSInterpreterFailure, tojson(cmdOutput)); + assert(/Intentionally thrown inside reduce function/.test(cmdOutput.errmsg), + () => "mapReduce didn't include the message from the exception thrown: " + + tojson(cmdOutput)); + assert(/myFunction@/.test(cmdOutput.errmsg), + () => "mapReduce didn't return the JavaScript stacktrace: " + tojson(cmdOutput)); + assert( + !cmdOutput.hasOwnProperty("stack"), + () => "mapReduce shouldn't return JavaScript stacktrace separately: " + tojson(cmdOutput)); + assert(!cmdOutput.hasOwnProperty("originalError"), + () => "mapReduce shouldn't return wrapped version of the error: " + tojson(cmdOutput)); // Test that the command fails with a JS interpreter failure error when the map function // throws. cmdOutput = db.runCommand({ mapReduce: coll.getName(), map: function() { - throw 42; + (function myFunction() { + throw new Error("Intentionally thrown inside map function"); + })(); }, reduce: function(key, value) { return Array.sum(value); @@ -39,4 +57,14 @@ out: {inline: 1} }); assert.commandFailedWithCode(cmdOutput, ErrorCodes.JSInterpreterFailure, tojson(cmdOutput)); + assert(/Intentionally thrown inside map function/.test(cmdOutput.errmsg), + () => "mapReduce didn't include the message from the exception thrown: " + + tojson(cmdOutput)); + assert(/myFunction@/.test(cmdOutput.errmsg), + () => "mapReduce didn't return the JavaScript stacktrace: " + tojson(cmdOutput)); + assert( + !cmdOutput.hasOwnProperty("stack"), + () => "mapReduce shouldn't return JavaScript stacktrace separately: " + tojson(cmdOutput)); + assert(!cmdOutput.hasOwnProperty("originalError"), + () => "mapReduce shouldn't return wrapped version of the error: " + tojson(cmdOutput)); }()); diff --git a/jstests/core/where_tolerates_js_exception.js b/jstests/core/where_tolerates_js_exception.js new file mode 100644 index 00000000000..b12a7c0a65e --- /dev/null +++ b/jstests/core/where_tolerates_js_exception.js @@ -0,0 +1,35 @@ +/** + * Test that $where fails gracefully when user-provided JavaScript code throws and that the user + * gets back the JavaScript stacktrace. + * + * @tags: [ + * requires_non_retryable_commands, + * requires_scripting, + * ] + */ +(function() { + "use strict"; + + const collection = db.where_tolerates_js_exception; + collection.drop(); + + assert.commandWorked(collection.save({a: 1})); + + const res = collection.runCommand("find", { + filter: { + $where: function myFunction() { + return a(); + } + } + }); + + assert.commandFailedWithCode(res, ErrorCodes.JSInterpreterFailure); + assert(/ReferenceError/.test(res.errmsg), + () => "$where didn't failed with a ReferenceError: " + tojson(res)); + assert(/myFunction@/.test(res.errmsg), + () => "$where didn't return the JavaScript stacktrace: " + tojson(res)); + assert(!res.hasOwnProperty("stack"), + () => "$where shouldn't return JavaScript stacktrace separately: " + tojson(res)); + assert(!res.hasOwnProperty("originalError"), + () => "$where shouldn't return wrapped version of the error: " + tojson(res)); +})(); diff --git a/jstests/noPassthrough/shell_scoped_thread.js b/jstests/noPassthrough/shell_scoped_thread.js index 7c2b92457a1..4b3bcc04167 100644 --- a/jstests/noPassthrough/shell_scoped_thread.js +++ b/jstests/noPassthrough/shell_scoped_thread.js @@ -93,6 +93,109 @@ load('jstests/libs/parallelTester.js'); // for ScopedThread } }); + function testUncaughtException(joinFn) { + const thread = new ScopedThread(function myFunction() { + throw new Error("Intentionally thrown inside ScopedThread"); + }); + thread.start(); + + let error = assert.throws(joinFn, [thread]); + assert( + /Intentionally thrown inside ScopedThread/.test(error.message), + () => + "Exception didn't include the message from the exception thrown in ScopedThread: " + + tojson(error.message)); + assert(/myFunction@/.test(error.stack), + () => "Exception doesn't contain stack frames from within the ScopedThread: " + + tojson(error.stack)); + assert(/testUncaughtException@/.test(error.stack), + () => "Exception doesn't contain stack frames from caller of the ScopedThread: " + + tojson(error.stack)); + + error = assert.throws(() => thread.join()); + assert.eq("Thread not running", + error.message, + "join() is expected to be called only once for the thread"); + + assert.eq(true, + thread.hasFailed(), + "Uncaught exception didn't cause thread to be marked as having failed"); + assert.doesNotThrow(() => thread.returnData(), + [], + "returnData() threw an exception after join() had been called"); + assert.eq(undefined, + thread.returnData(), + "returnData() shouldn't have anything to return if the thread failed"); + } + + tests.push(function testUncaughtExceptionAndWaitUsingJoin() { + testUncaughtException(thread => thread.join()); + }); + + // The returnData() method internally calls the join() method and should also throw an exception + // if the ScopedThread had an uncaught exception. + tests.push(function testUncaughtExceptionAndWaitUsingReturnData() { + testUncaughtException(thread => thread.returnData()); + }); + + tests.push(function testUncaughtExceptionInNativeCode() { + const thread = new ScopedThread(function myFunction() { + new Timestamp(-1); + }); + thread.start(); + + const error = assert.throws(() => thread.join()); + assert( + /Timestamp/.test(error.message), + () => + "Exception didn't include the message from the exception thrown in ScopedThread: " + + tojson(error.message)); + assert(/myFunction@/.test(error.stack), + () => "Exception doesn't contain stack frames from within the ScopedThread: " + + tojson(error.stack)); + }); + + tests.push(function testUncaughtExceptionFromNestedScopedThreads() { + const thread = new ScopedThread(function myFunction1() { + load("jstests/libs/parallelTester.js"); + + const thread = new ScopedThread(function myFunction2() { + load("jstests/libs/parallelTester.js"); + + const thread = new ScopedThread(function myFunction3() { + throw new Error("Intentionally thrown inside ScopedThread"); + }); + + thread.start(); + thread.join(); + }); + + thread.start(); + thread.join(); + }); + thread.start(); + + const error = assert.throws(() => thread.join()); + assert( + /Intentionally thrown inside ScopedThread/.test(error.message), + () => + "Exception didn't include the message from the exception thrown in ScopedThread: " + + tojson(error.message)); + assert( + /myFunction3@/.test(error.stack), + () => + "Exception doesn't contain stack frames from within the innermost ScopedThread: " + + tojson(error.stack)); + assert(/myFunction2@/.test(error.stack), + () => "Exception doesn't contain stack frames from within an inner ScopedThread: " + + tojson(error.stack)); + assert( + /myFunction1@/.test(error.stack), + () => + "Exception doesn't contain stack frames from within the outermost ScopedThread: " + + tojson(error.stack)); + }); + /* main */ tests.forEach((test) => { diff --git a/jstests/parallel/del.js b/jstests/parallel/del.js index 3128f89d05e..903bb983d1f 100644 --- a/jstests/parallel/del.js +++ b/jstests/parallel/del.js @@ -8,54 +8,84 @@ b = db.getSisterDB("foob"); a.dropDatabase(); b.dropDatabase(); -function del1(dbname, host, max) { - var m = new Mongo(host); - var db = m.getDB("foo" + dbname); - var t = db.del; - - while (!db.del_parallel.count()) { - var r = Math.random(); - var n = Math.floor(Math.random() * max); - if (r < .9) { - t.insert({x: n}); - } else if (r < .98) { - t.remove({x: n}); - } else if (r < .99) { - t.remove({x: {$lt: n}}); - } else { - t.remove({x: {$gt: n}}); +var kCursorKilledErrorCodes = [ + ErrorCodes.OperationFailed, + ErrorCodes.QueryPlanKilled, + ErrorCodes.CursorNotFound, +]; + +function del1(dbname, host, max, kCursorKilledErrorCodes) { + try { + var m = new Mongo(host); + var db = m.getDB("foo" + dbname); + var t = db.del; + + while (!db.del_parallel.count()) { + var r = Math.random(); + var n = Math.floor(Math.random() * max); + if (r < .9) { + t.insert({x: n}); + } else if (r < .98) { + t.remove({x: n}); + } else if (r < .99) { + t.remove({x: {$lt: n}}); + } else { + t.remove({x: {$gt: n}}); + } + if (r > .9999) + print(t.count()); + } + + return {ok: 1}; + } catch (e) { + if (kCursorKilledErrorCodes.includes(e.code)) { + // It is expected that the cursor may have been killed due to the database being + // dropped concurrently. + return {ok: 1}; } - if (r > .9999) - print(t.count()); + + throw e; } } -function del2(dbname, host, max) { - var m = new Mongo(host); - var db = m.getDB("foo" + dbname); - var t = db.del; - - while (!db.del_parallel.count()) { - var r = Math.random(); - var n = Math.floor(Math.random() * max); - var s = Math.random() > .5 ? 1 : -1; - - if (r < .5) { - t.findOne({x: n}); - } else if (r < .75) { - t.find({x: {$lt: n}}).sort({x: s}).itcount(); - } else { - t.find({x: {$gt: n}}).sort({x: s}).itcount(); +function del2(dbname, host, max, kCursorKilledErrorCodes) { + try { + var m = new Mongo(host); + var db = m.getDB("foo" + dbname); + var t = db.del; + + while (!db.del_parallel.count()) { + var r = Math.random(); + var n = Math.floor(Math.random() * max); + var s = Math.random() > .5 ? 1 : -1; + + if (r < .5) { + t.findOne({x: n}); + } else if (r < .75) { + t.find({x: {$lt: n}}).sort({x: s}).itcount(); + } else { + t.find({x: {$gt: n}}).sort({x: s}).itcount(); + } + } + + return {ok: 1}; + } catch (e) { + if (kCursorKilledErrorCodes.includes(e.code)) { + // It is expected that the cursor may have been killed due to the database being + // dropped concurrently. + return {ok: 1}; } + + throw e; } } all = []; -all.push(fork(del1, "a", HOST, N)); -all.push(fork(del2, "a", HOST, N)); -all.push(fork(del1, "b", HOST, N)); -all.push(fork(del2, "b", HOST, N)); +all.push(fork(del1, "a", HOST, N, kCursorKilledErrorCodes)); +all.push(fork(del2, "a", HOST, N, kCursorKilledErrorCodes)); +all.push(fork(del1, "b", HOST, N, kCursorKilledErrorCodes)); +all.push(fork(del2, "b", HOST, N, kCursorKilledErrorCodes)); for (i = 0; i < all.length; i++) all[i].start(); @@ -70,5 +100,6 @@ for (i = 0; i < 10; i++) { a.del_parallel.save({done: 1}); b.del_parallel.save({done: 1}); -for (i = 0; i < all.length; i++) - all[i].join(); +for (i = 0; i < all.length; i++) { + assert.commandWorked(all[i].returnData()); +} diff --git a/src/mongo/base/error_codes.err b/src/mongo/base/error_codes.err index 483498100a6..c4d3b46dc15 100644 --- a/src/mongo/base/error_codes.err +++ b/src/mongo/base/error_codes.err @@ -268,6 +268,7 @@ error_code("PreparedTransactionInProgress", 267); error_code("CannotBackup", 268); error_code("DataModifiedByRepair", 269); error_code("RepairedReplicaSetNode", 270); +error_code("JSInterpreterFailureWithStack", 271, extra="JSExceptionInfo") # Error codes 4000-8999 are reserved. # Non-sequential error codes (for compatibility only) diff --git a/src/mongo/scripting/SConscript b/src/mongo/scripting/SConscript index 0c489715917..c1726fba7cd 100644 --- a/src/mongo/scripting/SConscript +++ b/src/mongo/scripting/SConscript @@ -15,6 +15,7 @@ env.Library( 'deadline_monitor.cpp', 'dbdirectclient_factory.cpp', 'engine.cpp', + 'jsexception.cpp', 'utils.cpp', ], LIBDEPS=[ diff --git a/src/mongo/scripting/jsexception.cpp b/src/mongo/scripting/jsexception.cpp new file mode 100644 index 00000000000..51671963e6a --- /dev/null +++ b/src/mongo/scripting/jsexception.cpp @@ -0,0 +1,75 @@ +/** + * Copyright (C) 2018 MongoDB Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * As a special exception, the copyright holders give permission to link the + * code of portions of this program with the OpenSSL library under certain + * conditions as described in each individual source file and distribute + * linked combinations including the program with the OpenSSL library. You + * must comply with the GNU Affero General Public License in all respects + * for all of the code used other than as permitted herein. If you modify + * file(s) with this exception, you may extend this exception to your + * version of the file(s), but you are not obligated to do so. If you do not + * wish to do so, delete this exception statement from your version. If you + * delete this exception statement from all source files in the program, + * then also delete it in the license file. + */ + +#include "mongo/platform/basic.h" + +#include "mongo/scripting/jsexception.h" + +#include "mongo/base/init.h" +#include "mongo/bson/bsonobjbuilder.h" + +namespace mongo { +namespace { + +constexpr auto kCodeFieldName = "code"_sd; +constexpr auto kCodeNameFieldName = "codeName"_sd; +constexpr auto kOriginalErrorFieldName = "originalError"_sd; +constexpr auto kReasonFieldName = "errmsg"_sd; +constexpr auto kStackFieldName = "stack"_sd; + +} // namespace + +void JSExceptionInfo::serialize(BSONObjBuilder* builder) const { + builder->append(kStackFieldName, this->stack); + + { + BSONObjBuilder originalErrorBuilder(builder->subobjStart(kOriginalErrorFieldName)); + originalErrorBuilder.append(kReasonFieldName, this->originalError.reason()); + originalErrorBuilder.append(kCodeFieldName, this->originalError.code()); + originalErrorBuilder.append(kCodeNameFieldName, + ErrorCodes::errorString(this->originalError.code())); + if (auto extraInfo = this->originalError.extraInfo()) { + extraInfo->serialize(&originalErrorBuilder); + } + } +} + +std::shared_ptr<const ErrorExtraInfo> JSExceptionInfo::parse(const BSONObj& obj) { + auto stack = obj[kStackFieldName].String(); + + auto originalErrorObj = obj[kOriginalErrorFieldName].Obj(); + auto code = originalErrorObj[kCodeFieldName].Int(); + auto reason = originalErrorObj[kReasonFieldName].checkAndGetStringData(); + Status status(ErrorCodes::Error(code), reason, originalErrorObj); + + return std::make_shared<JSExceptionInfo>(std::move(stack), std::move(status)); +} + +MONGO_INIT_REGISTER_ERROR_EXTRA_INFO(JSExceptionInfo); + +} // namespace mongo diff --git a/src/mongo/scripting/jsexception.h b/src/mongo/scripting/jsexception.h new file mode 100644 index 00000000000..3871486d046 --- /dev/null +++ b/src/mongo/scripting/jsexception.h @@ -0,0 +1,63 @@ +/** + * Copyright (C) 2018 MongoDB Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * As a special exception, the copyright holders give permission to link the + * code of portions of this program with the OpenSSL library under certain + * conditions as described in each individual source file and distribute + * linked combinations including the program with the OpenSSL library. You + * must comply with the GNU Affero General Public License in all respects + * for all of the code used other than as permitted herein. If you modify + * file(s) with this exception, you may extend this exception to your + * version of the file(s), but you are not obligated to do so. If you do not + * wish to do so, delete this exception statement from your version. If you + * delete this exception statement from all source files in the program, + * then also delete it in the license file. + */ + +#pragma once + +#include <string> + +#include "mongo/base/error_codes.h" +#include "mongo/base/error_extra_info.h" +#include "mongo/base/status.h" + +namespace mongo { + +/** + * Represents information about a JavaScript exception. The "message" is stored as the Status's + * reason(), so only the "stack" is stored here. + * + * This class wraps an existing error and serializes it in a lossless way, so any other metadata + * about the JavaScript exception is also preserved. + */ +class JSExceptionInfo final : public ErrorExtraInfo { +public: + static constexpr auto code = ErrorCodes::JSInterpreterFailureWithStack; + + void serialize(BSONObjBuilder*) const override; + static std::shared_ptr<const ErrorExtraInfo> parse(const BSONObj&); + + explicit JSExceptionInfo(std::string stack_, Status originalError_) + : stack(std::move(stack_)), originalError(std::move(originalError_)) { + invariant(!stack.empty()); + invariant(!originalError.isOK()); + } + + const std::string stack; + const Status originalError; +}; + +} // namespace mongo diff --git a/src/mongo/scripting/mozjs/implscope.cpp b/src/mongo/scripting/mozjs/implscope.cpp index bf35606f2ba..c852899aa46 100644 --- a/src/mongo/scripting/mozjs/implscope.cpp +++ b/src/mongo/scripting/mozjs/implscope.cpp @@ -41,11 +41,13 @@ #include "mongo/db/server_parameters.h" #include "mongo/platform/decimal128.h" #include "mongo/platform/stack_locator.h" +#include "mongo/scripting/jsexception.h" #include "mongo/scripting/mozjs/objectwrapper.h" #include "mongo/scripting/mozjs/valuereader.h" #include "mongo/scripting/mozjs/valuewriter.h" #include "mongo/stdx/memory.h" #include "mongo/stdx/mutex.h" +#include "mongo/util/assert_util.h" #include "mongo/util/log.h" #include "mongo/util/scopeguard.h" @@ -125,10 +127,6 @@ void MozJSImplScope::_reportError(JSContext* cx, const char* message, JSErrorRep std::string exceptionMsg; try { - str::stream ss; - - ss << message; - // TODO: something far more elaborate that mimics the stack printing from v8 JS::RootedValue excn(cx); if (JS_GetPendingException(cx, &excn) && excn.isObject()) { @@ -138,23 +136,24 @@ void MozJSImplScope::_reportError(JSContext* cx, const char* message, JSErrorRep ObjectWrapper o(cx, obj); o.getValue("stack", &stack); - auto str = ValueWriter(cx, stack).toString(); + auto stackStr = ValueWriter(cx, stack).toString(); - if (str.empty()) { - ss << " @" << report->filename << ":" << report->lineno << ":" << report->column + if (stackStr.empty()) { + str::stream ss; + ss << "@" << report->filename << ":" << report->lineno << ":" << report->column << "\n"; - } else { - ss << " :\n" << str; + stackStr = ss; } - scope->_status = + auto status = jsExceptionToStatus(cx, excn, ErrorCodes::JSInterpreterFailure, message) - .withReason(ss); + .withReason(message); + scope->_status = {JSExceptionInfo(std::move(stackStr), std::move(status)), message}; return; } - exceptionMsg = ss; + exceptionMsg = message; } catch (const DBException& dbe) { exceptionMsg = "Unknown error occured while processing exception"; log() << exceptionMsg << ":" << dbe.toString() << ":" << message; @@ -526,12 +525,28 @@ auto MozJSImplScope::_runSafely(ImplScopeFunction&& functionToRun) -> decltype(f MozJSEntry entry(this); return functionToRun(); } catch (...) { + // There may have already been an error reported by SpiderMonkey. If not, then we use the + // active C++ exception as the cause of the error. + if (_status.isOK()) { + _status = exceptionToStatus(); + } + + if (auto extraInfo = _status.extraInfo<JSExceptionInfo>()) { + // We intentionally don't transmit an JSInterpreterFailureWithStack error over the wire + // because of the complexity it'd entail on the recipient to reach inside to the + // underlying error for how it should be handled. Instead, the error is unwrapped and + // the JavaScript stacktrace is included as part of the error message. + str::stream reasonWithStack; + reasonWithStack << extraInfo->originalError.reason() << " :\n" << extraInfo->stack; + _status = extraInfo->originalError.withReason(reasonWithStack); + } + _error = _status.reason(); // Clear the status state auto status = std::move(_status); uassertStatusOK(status); - throw; + MONGO_UNREACHABLE; } } @@ -609,6 +624,10 @@ void MozJSImplScope::_MozJSCreateFunction(StringData raw, JS::MutableHandleValue } BSONObj MozJSImplScope::callThreadArgs(const BSONObj& args) { + // The _runSafely() function is called for all codepaths of executing JavaScript other than + // callThreadArgs(). We intentionally don't unwrap the JSInterpreterFailureWithStack error to + // make it possible for the parent thread to chain its JavaScript stacktrace with the child + // thread's JavaScript stacktrace. MozJSEntry entry(this); JS::RootedValue function(_context); @@ -903,16 +922,21 @@ bool MozJSImplScope::_checkErrorState(bool success, bool reportError, bool asser ss << "[" << fnameStr << ":" << lineNum << ":" << colNum << "] "; } ss << ValueWriter(_context, excn).toString(); - if (stackStr != "") { - ss << "\nStack trace:\n" << stackStr << "----------\n"; - } - _status = Status(ErrorCodes::JSInterpreterFailure, ss); + _status = { + JSExceptionInfo(std::move(stackStr), Status(ErrorCodes::JSInterpreterFailure, ss)), + ss}; } else { _status = Status(ErrorCodes::UnknownError, "Unknown Failure from JSInterpreter"); } } - _error = _status.reason(); + if (auto extraInfo = _status.extraInfo<JSExceptionInfo>()) { + str::stream reasonWithStack; + reasonWithStack << _status.reason() << " :\n" << extraInfo->stack; + _error = reasonWithStack; + } else { + _error = _status.reason(); + } if (reportError) error() << redact(_error); diff --git a/src/mongo/scripting/mozjs/jsthread.cpp b/src/mongo/scripting/mozjs/jsthread.cpp index 5dccaf36bae..0072aa419e0 100644 --- a/src/mongo/scripting/mozjs/jsthread.cpp +++ b/src/mongo/scripting/mozjs/jsthread.cpp @@ -124,6 +124,8 @@ public: PR_JoinThread(_thread); _done = true; + + uassertStatusOK(_sharedData->getErrorStatus()); } /** @@ -134,7 +136,7 @@ public: bool hasFailed() const { uassert(ErrorCodes::JSInterpreterFailure, "Thread not started", _started); - return _sharedData->getErrored(); + return !_sharedData->getErrorStatus().isOK(); } BSONObj returnData() { @@ -154,16 +156,16 @@ private: */ class SharedData { public: - SharedData() : _errored(false) {} + SharedData() = default; - void setErrored(bool value) { - stdx::lock_guard<stdx::mutex> lck(_erroredMutex); - _errored = value; + void setErrorStatus(Status status) { + stdx::lock_guard<stdx::mutex> lck(_statusMutex); + _status = std::move(status); } - bool getErrored() { - stdx::lock_guard<stdx::mutex> lck(_erroredMutex); - return _errored; + Status getErrorStatus() { + stdx::lock_guard<stdx::mutex> lck(_statusMutex); + return _status; } /** @@ -176,8 +178,8 @@ private: std::string _stack; private: - stdx::mutex _erroredMutex; - bool _errored; + stdx::mutex _statusMutex; + Status _status = Status::OK(); }; /** @@ -197,10 +199,7 @@ private: thisv->_sharedData->_returnData = scope.callThreadArgs(thisv->_sharedData->_args); } catch (...) { auto status = exceptionToStatus(); - - log() << "js thread raised js exception: " << redact(status) - << thisv->_sharedData->_stack; - thisv->_sharedData->setErrored(true); + thisv->_sharedData->setErrorStatus(status); thisv->_sharedData->_returnData = BSON("ret" << BSONUndefined); } } diff --git a/src/mongo/scripting/mozjs/status.cpp b/src/mongo/scripting/mozjs/status.cpp index cb4ecb1bbd3..a78238d03ce 100644 --- a/src/mongo/scripting/mozjs/status.cpp +++ b/src/mongo/scripting/mozjs/status.cpp @@ -30,6 +30,7 @@ #include "mongo/scripting/mozjs/status.h" +#include "mongo/scripting/jsexception.h" #include "mongo/scripting/mozjs/implscope.h" #include "mongo/scripting/mozjs/internedstring.h" #include "mongo/scripting/mozjs/objectwrapper.h" @@ -76,6 +77,14 @@ void MongoStatusInfo::fromStatus(JSContext* cx, Status status, JS::MutableHandle JSPROP_ENUMERATE | JSPROP_SHARED, smUtils::wrapConstrainedMethod<Functions::reason, false, MongoStatusInfo>); + // We intentionally omit JSPROP_ENUMERATE to match how Error.prototype.stack is a non-enumerable + // property. + thisvObj.defineProperty( + InternedString::stack, + undef, + JSPROP_SHARED, + smUtils::wrapConstrainedMethod<Functions::stack, false, MongoStatusInfo>); + JS_SetPrivate(thisv, scope->trackedNew<Status>(std::move(status))); value.setObjectOrNull(thisv); @@ -106,6 +115,41 @@ void MongoStatusInfo::Functions::reason::call(JSContext* cx, JS::CallArgs args) ValueReader(cx, args.rval()).fromStringData(toStatus(cx, args.thisv()).reason()); } +void MongoStatusInfo::Functions::stack::call(JSContext* cx, JS::CallArgs args) { + JS::RootedObject thisv(cx, args.thisv().toObjectOrNull()); + JS::RootedObject parent(cx); + + if (!JS_GetPrototype(cx, thisv, &parent)) { + uasserted(ErrorCodes::JSInterpreterFailure, "Couldn't get prototype"); + } + + ObjectWrapper parentWrapper(cx, parent); + + auto status = toStatus(cx, args.thisv()); + if (auto extraInfo = status.extraInfo<JSExceptionInfo>()) { + // 'status' represents an uncaught JavaScript exception that was handled in C++. It is + // expected to have been thrown by a JSThread. We chain its stacktrace together with the + // stacktrace of the JavaScript exception in the current thread in order to show more + // context about the latter's cause. + JS::RootedValue stack(cx); + ValueReader(cx, &stack) + .fromStringData(extraInfo->stack + parentWrapper.getString(InternedString::stack)); + + // We redefine the "stack" property as the combined JavaScript stacktrace. It is important + // that we omit JSPROP_SHARED to the thisvObj.defineProperty() call in order to have + // SpiderMonkey allocate memory for the string value. We also intentionally omit + // JSPROP_ENUMERATE to match how Error.prototype.stack is a non-enumerable property. + ObjectWrapper thisvObj(cx, args.thisv()); + thisvObj.defineProperty(InternedString::stack, stack, 0U); + + // We intentionally use thisvObj.getValue() to access the "stack" property to implicitly + // verify it has been redefined correctly, as the alternative would be infinite recursion. + thisvObj.getValue(InternedString::stack, args.rval()); + } else { + parentWrapper.getValue(InternedString::stack, args.rval()); + } +} + void MongoStatusInfo::postInstall(JSContext* cx, JS::HandleObject global, JS::HandleObject proto) { auto scope = getScope(cx); diff --git a/src/mongo/scripting/mozjs/status.h b/src/mongo/scripting/mozjs/status.h index d930bdb8df9..8a066936520 100644 --- a/src/mongo/scripting/mozjs/status.h +++ b/src/mongo/scripting/mozjs/status.h @@ -51,6 +51,7 @@ struct MongoStatusInfo : public BaseInfo { struct Functions { MONGO_DECLARE_JS_FUNCTION(code); MONGO_DECLARE_JS_FUNCTION(reason); + MONGO_DECLARE_JS_FUNCTION(stack); }; static void postInstall(JSContext* cx, JS::HandleObject global, JS::HandleObject proto); |