Skip to content
New issue

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

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

Already on GitHub? # to your account

[nfc] moving jsg exception handling code together #583

Merged
merged 1 commit into from
Apr 28, 2023
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
25 changes: 21 additions & 4 deletions src/workerd/jsg/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,40 @@ wd_cc_library(
name = "jsg",
srcs = glob(
["*.c++"],
exclude = ["*-test.c++"],
exclude = [
"exception.c++",
"*-test.c++",
],
),
hdrs = glob(
["*.h"],
exclude = ["rtti.h"],
exclude = [
"exception.h",
"rtti.h",
],
),
visibility = ["//visibility:public"],
deps = [
":exception",
":modules_capnp",
"//src/workerd/util",
"//src/workerd/util:sentry",
"//src/workerd/util:thread-scopes",
"//src/workerd/util",
"@capnp-cpp//src/kj",
"@workerd-v8//:v8",
],
)

wd_cc_library(
name = "exception",
srcs = ["exception.c++"],
hdrs = ["exception.h"],
visibility = ["//visibility:public"],
deps = [
"@capnp-cpp//src/kj",
],
)

wd_cc_capnp_library(
name = "rtti_capnp",
srcs = ["rtti.capnp"],
Expand All @@ -34,11 +51,11 @@ wd_cc_capnp_library(
js_capnp_library(
name = "rtti_capnp_js",
srcs = ["rtti.capnp"],
visibility = ["//visibility:public"],
target_compatible_with = select({
"@platforms//os:windows": ["@platforms//:incompatible"],
"//conditions:default": [],
}),
visibility = ["//visibility:public"],
)

npm_package(
Expand Down
181 changes: 181 additions & 0 deletions src/workerd/jsg/exception.c++
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#include "exception.h"

#include <kj/debug.h>

namespace workerd::jsg {

kj::StringPtr stripRemoteExceptionPrefix(kj::StringPtr internalMessage) {
while (internalMessage.startsWith("remote exception: "_kj)) {
// Exception was passed over RPC.
internalMessage = internalMessage.slice("remote exception: "_kj.size());
}
return internalMessage;
}

namespace {
constexpr auto ERROR_PREFIX_DELIM = "; "_kj;
constexpr auto ERROR_REMOTE_PREFIX = "remote."_kj;
constexpr auto ERROR_TUNNELED_PREFIX_CFJS = "cfjs."_kj;
constexpr auto ERROR_TUNNELED_PREFIX_JSG = "jsg."_kj;
constexpr auto ERROR_INTERNAL_SOURCE_PREFIX_CFJS = "cfjs-internal."_kj;
constexpr auto ERROR_INTERNAL_SOURCE_PREFIX_JSG = "jsg-internal."_kj;
}

TunneledErrorType tunneledErrorType(kj::StringPtr internalMessage) {
// A tunneled error in an internal message is prefixed by one of the following patterns,
// anchored at the beginning of the message:
// jsg.
// expected <...>; jsg.
// broken.<...>; jsg.
// where <...> is some failed expectation from e.g. a KJ_REQUIRE.
//
// A tunneled error might have a prefix "remote.". This indicates it was tunneled from an actor or
// from one worker to another. If this prefix is present, we set `isFromRemote` to true, remove
// the "remote." prefix, and continue processing the rest of the error.
//
// Additionally, a prefix of `jsg-internal.` instead of `jsg.` means "throw a specific
// JavaScript error type, but still hide the message text from the app".

internalMessage = stripRemoteExceptionPrefix(internalMessage);

struct Properties {
bool isFromRemote = false;
bool isDurableObjectReset = false;
};
Properties properties;

// Remove `remote.` (if present). Note that there are cases where we return a tunneled error
// through multiple workers, so let's be paranoid and allow for multiple "remote." prefxies.
while (internalMessage.startsWith(ERROR_REMOTE_PREFIX)) {
properties.isFromRemote = true;
internalMessage = internalMessage.slice(ERROR_REMOTE_PREFIX.size());
}

auto findDelim = [](kj::StringPtr msg) -> size_t {
// Either return 0 if no matches or the index past the first delim if there are.
auto match = strstr(msg.cStr(), ERROR_PREFIX_DELIM.cStr());
if (!match) {
return 0;
} else {
return (match - msg.cStr()) + ERROR_PREFIX_DELIM.size();
}
};

auto tryExtractError = [](kj::StringPtr msg, Properties properties)
-> kj::Maybe<TunneledErrorType> {
if (msg.startsWith(ERROR_TUNNELED_PREFIX_CFJS)) {
return TunneledErrorType{
.message = msg.slice(ERROR_TUNNELED_PREFIX_CFJS.size()),
.isJsgError = true,
.isInternal = false,
.isFromRemote = properties.isFromRemote,
.isDurableObjectReset = properties.isDurableObjectReset,
};
}
if (msg.startsWith(ERROR_TUNNELED_PREFIX_JSG)) {
return TunneledErrorType{
.message = msg.slice(ERROR_TUNNELED_PREFIX_JSG.size()),
.isJsgError = true,
.isInternal = false,
.isFromRemote = properties.isFromRemote,
.isDurableObjectReset = properties.isDurableObjectReset,
};
}
if (msg.startsWith(ERROR_INTERNAL_SOURCE_PREFIX_CFJS)) {
return TunneledErrorType{
.message = msg.slice(ERROR_INTERNAL_SOURCE_PREFIX_CFJS.size()),
.isJsgError = true,
.isInternal = true,
.isFromRemote = properties.isFromRemote,
.isDurableObjectReset = properties.isDurableObjectReset,
};
}
if (msg.startsWith(ERROR_INTERNAL_SOURCE_PREFIX_JSG)) {
return TunneledErrorType{
.message = msg.slice(ERROR_INTERNAL_SOURCE_PREFIX_JSG.size()),
.isJsgError = true,
.isInternal = true,
.isFromRemote = properties.isFromRemote,
.isDurableObjectReset = properties.isDurableObjectReset,
};
}

return nullptr;
};

auto makeDefaultError = [](kj::StringPtr msg, Properties properties) {
return TunneledErrorType{
.message = msg,
.isJsgError = false,
.isInternal = true,
.isFromRemote = properties.isFromRemote,
.isDurableObjectReset = properties.isDurableObjectReset,
};
};

if (internalMessage.startsWith("expected ")) {
// This was a test assertion, peel away delimiters until either we find an error or there are
// none left.
auto idx = findDelim(internalMessage);
while(idx) {
internalMessage = internalMessage.slice(idx);
KJ_IF_MAYBE(e, tryExtractError(internalMessage, properties)) {
return kj::mv(*e);
}
idx = findDelim(internalMessage);
}

// We failed to extract an expected error, make a default one.
return makeDefaultError(internalMessage, properties);
}

while (internalMessage.startsWith("broken.")) {
properties.isDurableObjectReset = true;

// Trim away all broken prefixes, they are not allowed to have internal delimiters.
internalMessage = internalMessage.slice(findDelim(internalMessage));
}

// There are no prefixes left, just try to extract the error.
KJ_IF_MAYBE(e, tryExtractError(internalMessage, properties)) {
return kj::mv(*e);
} else {
return makeDefaultError(internalMessage, properties);
}
}

bool isTunneledException(kj::StringPtr internalMessage) {
return !tunneledErrorType(internalMessage).isInternal;
}

bool isDoNotLogException(kj::StringPtr internalMessage) {
return strstr(internalMessage.cStr(), "worker_do_not_log") != nullptr;
}

kj::String annotateBroken(kj::StringPtr internalMessage, kj::StringPtr brokenessReason) {
// TODO(soon) Once we support multiple brokenness reasons, we can make this much simpler.

KJ_LOG(INFO, "Annotating with brokenness", internalMessage, brokenessReason);
auto tunneledInfo = tunneledErrorType(internalMessage);

kj::StringPtr remotePrefix;
if (tunneledInfo.isFromRemote) {
remotePrefix = ERROR_REMOTE_PREFIX;
}

kj::StringPtr prefixType = ERROR_TUNNELED_PREFIX_JSG;
kj::StringPtr internalErrorType;
if (tunneledInfo.isInternal) {
prefixType = ERROR_INTERNAL_SOURCE_PREFIX_JSG;
if (!tunneledInfo.isJsgError) {
// This is not a JSG error, so we need to give it a type.
internalErrorType = "Error: "_kj;
}
}

return kj::str(
remotePrefix, brokenessReason, ERROR_PREFIX_DELIM, prefixType, internalErrorType,
tunneledInfo.message);
}

} // namespace workerd::jsg
145 changes: 145 additions & 0 deletions src/workerd/jsg/exception.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright (c) 2017-2022 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

#pragma once

#include <kj/string.h>

namespace workerd::jsg {

#define JSG_EXCEPTION(jsErrorType) JSG_ERROR_ ## jsErrorType
#define JSG_DOM_EXCEPTION(name) "jsg.DOMException(" name ")"
#define JSG_INTERNAL_DOM_EXCEPTION(name) "jsg-internal.DOMException(" name ")"

#define JSG_ERROR_DOMOperationError JSG_DOM_EXCEPTION("OperationError")
#define JSG_ERROR_DOMDataError JSG_DOM_EXCEPTION("DataError")
#define JSG_ERROR_DOMDataCloneError JSG_DOM_EXCEPTION("DataCloneError")
#define JSG_ERROR_DOMInvalidAccessError JSG_DOM_EXCEPTION("InvalidAccessError")
#define JSG_ERROR_DOMInvalidStateError JSG_DOM_EXCEPTION("InvalidStateError")
#define JSG_ERROR_DOMInvalidCharacterError JSG_DOM_EXCEPTION("InvalidCharacterError")
#define JSG_ERROR_DOMNotSupportedError JSG_DOM_EXCEPTION("NotSupportedError")
#define JSG_ERROR_DOMSyntaxError JSG_DOM_EXCEPTION("SyntaxError")
#define JSG_ERROR_DOMTimeoutError JSG_DOM_EXCEPTION("TimeoutError")
#define JSG_ERROR_DOMTypeMismatchError JSG_DOM_EXCEPTION("TypeMismatchError")
#define JSG_ERROR_DOMQuotaExceededError JSG_DOM_EXCEPTION("QuotaExceededError")
#define JSG_ERROR_DOMAbortError JSG_DOM_EXCEPTION("AbortError")

#define JSG_ERROR_TypeError "jsg.TypeError"
#define JSG_ERROR_Error "jsg.Error"
#define JSG_ERROR_RangeError "jsg.RangeError"

#define JSG_ERROR_InternalDOMOperationError JSG_INTERNAL_DOM_EXCEPTION("OperationError")

#define JSG_KJ_EXCEPTION(type, jsErrorType, ...) \
kj::Exception(kj::Exception::Type::type, __FILE__, __LINE__, \
kj::str(JSG_EXCEPTION(jsErrorType) ": ", __VA_ARGS__))

#define JSG_ASSERT(cond, jsErrorType, ...) \
KJ_ASSERT(cond, kj::str(JSG_EXCEPTION(jsErrorType) ": ", ##__VA_ARGS__))

#define JSG_REQUIRE(cond, jsErrorType, ...) \
KJ_REQUIRE(cond, kj::str(JSG_EXCEPTION(jsErrorType) ": ", ##__VA_ARGS__))
// Unlike KJ_REQUIRE, JSG_REQUIRE passes all message arguments through kj::str which makes it
// "prettier". This does have some implications like if there's only string literal arguments then
// there's an unnecessary heap copy. More importantly none of the expressions you pass in end up in
// the resultant string AND you are responsible for formatting the resultant string. For example,
// KJ_REQUIRE(false, "some message", x) formats it like "some message; x = 5". The "equivalent" via
// this macro would be JSG_REQUIRE(false, "some message ", x); which would yield a string like
// "some message 5" (or JSG_REQUIRE(false, "some message; x = ", x) if you wanted identical output,
// but then why not use KJ_REQUIRE).

#define JSG_REQUIRE_NONNULL(value, jsErrorType, ...) \
KJ_REQUIRE_NONNULL(value, kj::str(JSG_EXCEPTION(jsErrorType) ": ", ##__VA_ARGS__))
// JSG_REQUIRE + KJ_REQUIRE_NONNULL.

#define JSG_FAIL_REQUIRE(jsErrorType, ...) \
KJ_FAIL_REQUIRE(kj::str(JSG_EXCEPTION(jsErrorType) ": ", ##__VA_ARGS__))
// JSG_REQUIRE + KJ_FAIL_REQUIRE

#define JSG_WARN_ONCE(msg, ...) \
static bool logOnce KJ_UNUSED = ([&] { \
KJ_LOG(WARNING, msg, ##__VA_ARGS__); \
return true; \
})() \

// Conditionally log a warning, at most once. Useful for determining if code changes would break
// any existing scripts.
#define JSG_WARN_ONCE_IF(cond, msg, ...) \
if (cond) { \
JSG_WARN_ONCE(msg, ##__VA_ARGS__); \
}

// These are passthrough functions to KJ. We expect the error string to be
// surfaced to the application.

#define _JSG_INTERNAL_REQUIRE(cond, jsErrorType, ...) \
do { \
try { \
KJ_REQUIRE(cond, jsErrorType ": Cloudflare internal error."); \
} catch (const kj::Exception& e) { \
KJ_LOG(ERROR, e, ##__VA_ARGS__); \
throw e; \
} \
} while (0)

#define _JSG_INTERNAL_REQUIRE_NONNULL(value, jsErrorType, ...) \
([&]() -> decltype(auto) { \
try { \
return KJ_REQUIRE_NONNULL(value, jsErrorType ": Cloudflare internal error."); \
} catch (const kj::Exception& e) { \
KJ_LOG(ERROR, e, ##__VA_ARGS__); \
throw e; \
} \
}())

#define _JSG_INTERNAL_FAIL_REQUIRE(jsErrorType, ...) \
do { \
try { \
KJ_FAIL_REQUIRE(jsErrorType ": Cloudflare internal error."); \
} catch (const kj::Exception& e) { \
KJ_LOG(ERROR, e, ##__VA_ARGS__); \
throw e; \
} \
} while (0)

bool isTunneledException(kj::StringPtr internalMessage);
// Given a KJ exception's description, returns whether it contains a tunneled exception that could
// be converted back to JavaScript via makeInternalError().

bool isDoNotLogException(kj::StringPtr internalMessage);
// Given a KJ exception's description, returns whether it contains the magic constant that indicates
// the exception is the script's fault and isn't worth logging.

// Log an exception ala LOG_EXCEPTION, but only if it is worth logging and not a tunneled exception.
#define LOG_EXCEPTION_IF_INTERNAL(context, exception) \
if (!jsg::isTunneledException(exception.getDescription()) && \
!jsg::isDoNotLogException(exception.getDescription())) { \
LOG_EXCEPTION(context, exception); \
}


struct TunneledErrorType {
kj::StringPtr message;
// The original error message stripped of prefixes.

bool isJsgError;
// Was this error prefixed by JSG already?

bool isInternal;
// Is this error internal? If so, the error message should be logged to syslog and hidden from
// the app.

bool isFromRemote;
// Was the error tunneled from either a worker or an actor?

bool isDurableObjectReset;
// Was the error created because a durable object is broken?
};

TunneledErrorType tunneledErrorType(kj::StringPtr internalMessage);

kj::String annotateBroken(kj::StringPtr internalMessage, kj::StringPtr brokenessReason);
// Annotate an internal message with the corresponding brokeness reason.

} // namespace workerd::jsg
Loading