Skip to content

Commit

Permalink
SERVER-35154 Propagate JS exceptions through ScopedThread#join().
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
visemet committed Sep 19, 2018
1 parent 78112be commit f43ea7a
Show file tree
Hide file tree
Showing 13 changed files with 482 additions and 96 deletions.
19 changes: 0 additions & 19 deletions jstests/core/error2.js

This file was deleted.

38 changes: 33 additions & 5 deletions jstests/core/mr_tolerates_js_exception.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,23 +24,47 @@
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);
},
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));
}());
35 changes: 35 additions & 0 deletions jstests/core/where_tolerates_js_exception.js
Original file line number Diff line number Diff line change
@@ -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));
})();
103 changes: 103 additions & 0 deletions jstests/noPassthrough/shell_scoped_thread.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
111 changes: 71 additions & 40 deletions jstests/parallel/del.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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());
}
1 change: 1 addition & 0 deletions src/mongo/base/error_codes.err
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/mongo/scripting/SConscript
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ env.Library(
'deadline_monitor.cpp',
'dbdirectclient_factory.cpp',
'engine.cpp',
'jsexception.cpp',
'utils.cpp',
],
LIBDEPS=[
Expand Down
Loading

0 comments on commit f43ea7a

Please sign in to comment.