Skip to content
Draft
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
27 changes: 27 additions & 0 deletions src/rust/jsg-test/tests/internal_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE file or at:
// https://opensource.org/licenses/Apache-2.0

//! An internal error reaches JavaScript without its message.

use jsg::FromJS;

#[test]
fn internal_error_is_redacted_when_thrown() {
crate::Harness::new().run_in_context(|lock, _ctx| {
let thrown =
lock.try_catch(|_lock| Err::<(), _>(jsg::Error::new_internal_error("secret detail")))?;
let Err(exception) = thrown else {
panic!("the internal error was not thrown");
};
let err = jsg::Error::from_js(lock, exception)?;
assert_eq!(err.name, jsg::ExceptionType::Error);
assert!(
err.message.starts_with("internal error; reference = "),
"{}",
err.message
);
assert!(!err.message.contains("secret detail"), "{}", err.message);
Ok(())
});
}
1 change: 1 addition & 0 deletions src/rust/jsg-test/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod gc;
mod global;
mod harness;
mod integer;
mod internal_error;
mod isolates;
mod jsg_oneof;
mod jsg_struct;
Expand Down
15 changes: 8 additions & 7 deletions src/rust/jsg/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,13 +453,14 @@ impl Error {
})
}

/// Like `new_type_error()`, but marks the resulting `Error` `is_internal`, so
/// `Lock::throw_exception()` redacts `message` instead of exposing it to guest JS.
/// The JS-visible error type ends up as plain `Error` regardless of the type set
/// here (`Lock::throw_internal_error()` -> C++ `makeInternalError()` always throws
/// a plain `Error`), so `name` only matters if something inspects the `Error`
/// value directly without throwing it (e.g. in tests).
fn new_internal_error(message: impl Into<String>) -> Self {
/// An error that only a bug causes, like a failed C++ `KJ_ASSERT`. Throwing it
/// (`Lock::throw_exception()`) redacts `message`: JavaScript gets a generic
/// `"internal error; reference = <id>"` `Error`, and `message` is logged with that ID.
/// The JS-visible error type is plain `Error` regardless of the type set here
/// (`Lock::throw_internal_error()` -> C++ `makeInternalError()` always throws a plain
/// `Error`), so `name` only matters if something inspects the `Error` value directly
/// without throwing it (e.g. in tests).
pub fn new_internal_error(message: impl Into<String>) -> Self {
Self {
name: ExceptionType::TypeError,
message: message.into(),
Expand Down
1 change: 1 addition & 0 deletions src/workerd/api/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ kj_test(
":memory-cache",
"//src/workerd/io:trace",
"//src/workerd/tests:test-fixture",
"//src/workerd/util:thread-scopes",
],
)

Expand Down
107 changes: 107 additions & 0 deletions src/workerd/api/memory-cache-test.c++
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

#include <workerd/io/trace.h>
#include <workerd/tests/test-fixture.h>
#include <workerd/util/thread-scopes.h>

#include <kj/test.h>
#include <kj/thread.h>

#include <algorithm>

namespace workerd::api {
namespace {

Expand Down Expand Up @@ -209,5 +212,109 @@ KJ_TEST("fallback callback stores the value and survives binding destruction") {
});
}

// Records the spans of a request as they end, each as "name <- parent: key=type:value ...", with
// its tags in key order.
class SpanRecorder final: public SpanObserver {
public:
SpanRecorder(kj::Vector<kj::String>& spans, kj::StringPtr parent)
: spans(spans),
parent(kj::str(parent)) {}

kj::Rc<SpanObserver> newChild() override {
return kj::rc<SpanRecorder>(spans, name);
}
void onOpen(kj::ConstString operationName, kj::Date) override {
name = kj::str(operationName);
}
void onClose(kj::Date, Span::TagMap&& tags, kj::Vector<Span::Log>&&) override {
auto entries = KJ_MAP(entry, tags) {
KJ_SWITCH_ONEOF(entry.value) {
KJ_CASE_ONEOF(str, kj::ConstString) {
return kj::str(entry.key, "=str:", str);
}
KJ_CASE_ONEOF(b, bool) {
return kj::str(entry.key, "=bool:", b);
}
KJ_CASE_ONEOF(d, double) {
return kj::str(entry.key, "=float:", d);
}
KJ_CASE_ONEOF(i, int64_t) {
return kj::str(entry.key, "=int:", i);
}
}
KJ_UNREACHABLE;
};
std::sort(entries.begin(), entries.end());
spans.add(kj::str(name, " <- ", parent, ": ", kj::strArray(entries, " ")));
}

private:
kj::Vector<kj::String>& spans;
kj::String parent;
kj::String name;
};

class SpanRequestObserver final: public RequestObserver {
public:
explicit SpanRequestObserver(kj::Vector<kj::String>& spans)
: span(kj::rc<SpanRecorder>(spans, "none"_kj), "request"_kjc) {}

SpanParent getSpan() override {
return SpanParent(span);
}

private:
SpanBuilder span;
};

KJ_TEST("a fallback whose result is rejected is tagged as failed, without overwriting a tag") {
// In predictable mode, overwriting a span's tag logs an error, which fails this test. It logs
// only the first overwrite in the process, so no other test here may overwrite one.
setPredictableModeForTest();
MemoryCacheProvider provider;
kj::Vector<kj::String> spans;
TestFixture fixture({
.mainModuleSource = R"SCRIPT(
export default {
async fetch() {
const settle = (promise) => promise.then(() => {}, () => {});
await settle(globalThis.CACHE.read("nan", () => ({ value: 1, expiration: NaN })));
await settle(globalThis.CACHE.read("function", () => ({ value: () => {} })));
return new Response("ok");
}
};
)SCRIPT"_kj,
.requestObserverFactory =
kj::Function<kj::Own<RequestObserver>()>([&spans]() -> kj::Own<RequestObserver> {
return kj::refcounted<SpanRequestObserver>(spans);
}),
});
fixture.runInIoContext([&](const TestFixture::Environment& env) {
auto& handler = KJ_REQUIRE_NONNULL(env.js.tryGetTypeHandler<jsg::Ref<MemoryCache>>());
auto cache =
handler.wrap(env.js, env.js.alloc<MemoryCache>(provider.getUse(kj::none, testLimits())));
jsg::check(
env.js.v8Context()->Global()->Set(env.js.v8Context(), env.js.str("CACHE"_kj), cache));
});
auto response = fixture.runRequest(kj::HttpMethod::GET, "http://worker/"_kj, ""_kj);
KJ_EXPECT(response.statusCode == 200, response.body);

kj::Vector<kj::StringPtr> fallbacks;
for (auto& span: spans) {
if (span.startsWith("memory_cache_fallback ")) fallbacks.add(span);
}
KJ_ASSERT(fallbacks.size() == 2, kj::strArray(spans, "\n"));
KJ_EXPECT(fallbacks[0] ==
"memory_cache_fallback <- memory_cache_read: "
"fallback_error=str:TypeError: Expiration time must not be NaN. "
"fallback_result_size=float:4 fallback_success=bool:false key=str:nan",
fallbacks[0]);
// kj::str() of the DOMException that serialization throws is empty.
KJ_EXPECT(fallbacks[1] ==
"memory_cache_fallback <- memory_cache_read: fallback_error=str: "
"fallback_success=bool:false key=str:function",
fallbacks[1]);
}

} // namespace
} // namespace workerd::api
5 changes: 3 additions & 2 deletions src/workerd/api/memory-cache.c++
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,6 @@ jsg::Promise<jsg::JsRef<jsg::JsValue>> MemoryCache::read(jsg::Lock& js,
CacheValueProduceResult result) mutable -> jsg::JsRef<jsg::JsValue> {
// NOTE: `callback` is IoPtr, not IoOwn. The catch block gets the IoOwn, which
// ensures the object still exists at this point.
fallbackSpan->setTag("fallback_success"_kjc, true);

auto serialized = hackySerialize(js, result.value);
fallbackSpan->setTag(
"fallback_result_size"_kjc, static_cast<double>(serialized.size()));
Expand All @@ -379,6 +377,9 @@ jsg::Promise<jsg::JsRef<jsg::JsValue>> MemoryCache::read(jsg::Lock& js,
} else {
fallbackSpan->setTag("has_expiration"_kjc, false);
}
// Only now, so that the catch block, which handles what this throws, does not
// overwrite it.
fallbackSpan->setTag("fallback_success"_kjc, true);
(*callback)(MemoryCacheUse::FallbackResult{kj::mv(serialized), result.expiration},
*fallbackSpan);
return kj::mv(result.value);
Expand Down
Loading
Loading