Skip to content

Commit 232c276

Browse files
Merge pull request #6021 from cloudflare/harris/2026-02-05-jsg-try-catch
Implement JSG_TRY / JSG_CATCH macros
2 parents bca5351 + ca446b7 commit 232c276

File tree

11 files changed

+338
-42
lines changed

11 files changed

+338
-42
lines changed

src/workerd/api/crypto/crypto.c++

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -806,13 +806,16 @@ DigestStream::DigestStream(kj::Own<WritableStreamController> controller,
806806
state(Ready(kj::mv(algorithm), kj::mv(resolver))) {}
807807

808808
void DigestStream::dispose(jsg::Lock& js) {
809-
js.tryCatch([&] {
809+
JSG_TRY(js) {
810810
KJ_IF_SOME(ready, state.tryGet<Ready>()) {
811811
auto reason = js.typeError("The DigestStream was disposed.");
812812
ready.resolver.reject(js, reason);
813813
state.init<StreamStates::Errored>(js.v8Ref<v8::Value>(reason));
814814
}
815-
}, [&](jsg::Value exception) { js.throwException(kj::mv(exception)); });
815+
}
816+
JSG_CATCH(exception) {
817+
js.throwException(kj::mv(exception));
818+
}
816819
}
817820

818821
void DigestStream::visitForMemoryInfo(jsg::MemoryTracker& tracker) const {

src/workerd/api/memory-cache.c++

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,12 @@ void SharedMemoryCache::Use::delete_(const kj::String& key) const {
430430
// Attempts to serialize a JavaScript value. If that fails, this function throws
431431
// a tunneled exception, see jsg::createTunneledException().
432432
static kj::Own<CacheValue> hackySerialize(jsg::Lock& js, jsg::JsRef<jsg::JsValue>& value) {
433-
return js.tryCatch([&]() -> kj::Own<CacheValue> {
433+
JSG_TRY(js) {
434434
jsg::Serializer serializer(js);
435435
serializer.write(js, value.getHandle(js));
436436
return kj::atomicRefcounted<CacheValue>(serializer.release().data);
437-
}, [&](jsg::Value&& exception) -> kj::Own<CacheValue> {
437+
}
438+
JSG_CATCH(exception) {
438439
// We run into big problems with tunneled exceptions here. When
439440
// the toString() function of the JavaScript error is not marked
440441
// as side effect free, tunneling the exception fails entirely
@@ -450,7 +451,7 @@ static kj::Own<CacheValue> hackySerialize(jsg::Lock& js, jsg::JsRef<jsg::JsValue
450451
// This is still pretty bad. We lose the original error stack.
451452
// TODO(later): remove string-based error tunneling
452453
throw js.exceptionToKj(kj::mv(exception));
453-
});
454+
}
454455
}
455456

456457
jsg::Promise<jsg::JsRef<jsg::JsValue>> MemoryCache::read(jsg::Lock& js,

src/workerd/api/messagechannel.c++

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,18 @@ MessagePort::MessagePort()
4242
}
4343

4444
void MessagePort::dispatchMessage(jsg::Lock& js, const jsg::JsValue& value) {
45-
js.tryCatch([&] {
45+
JSG_TRY(js) {
4646
auto message = js.alloc<MessageEvent>(js, kj::str("message"), value, kj::String(), JSG_THIS);
4747
dispatchEventImpl(js, kj::mv(message));
48-
}, [&](jsg::Value exception) {
48+
}
49+
JSG_CATCH(exception) {
4950
// There was an error dispatching the message event.
5051
// We will dispatch a messageerror event instead.
5152
auto message = js.alloc<MessageEvent>(
5253
js, kj::str("message"), jsg::JsValue(exception.getHandle(js)), kj::String(), JSG_THIS);
5354
dispatchEventImpl(js, kj::mv(message));
5455
// Now, if this dispatchEventImpl throws, we just blow up. Don't try to catch it.
55-
});
56+
}
5657
}
5758

5859
// Deliver the message to this port, buffering if necessary if the port

src/workerd/api/streams/standard.c++

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -505,31 +505,43 @@ jsg::Promise<void> maybeRunAlgorithm(
505505
// throws synchronously, we have to convert that synchronous throw
506506
// into a proper rejected jsg::Promise.
507507
KJ_IF_SOME(algorithm, maybeAlgorithm) {
508-
// We need two layers of tryCatch here, unfortunately. The inner layer
508+
// We need two layers of JSG_TRY here, unfortunately. The inner layer
509509
// covers the algorithm implementation itself and is our typical error
510510
// handling path. It ensures that if the algorithm throws an exception,
511511
// that is properly converted in to a rejected promise that is *then*
512-
// handled by the onFailure handler that is passed in. The outer tryCatch
512+
// handled by the onFailure handler that is passed in. The outer JSG_TRY
513513
// handles the rare and generally unexpected failure of the calls to
514514
// .then() itself, which can throw JS exceptions synchronously in certain
515515
// rare cases. For those we return a rejected promise but do not call the
516516
// onFailure case since such errors are generally indicative of a fatal
517517
// condition in the isolate (e.g. out of memory, other fatal exception, etc).
518-
return js.tryCatch([&] {
518+
JSG_TRY(js) {
519519
KJ_IF_SOME(ioContext, IoContext::tryCurrent()) {
520-
return js
521-
.tryCatch([&] { return algorithm(js, kj::fwd<decltype(args)>(args)...); },
522-
[&](jsg::Value&& exception) { return js.rejectedPromise<void>(kj::mv(exception)); })
523-
.then(js, ioContext.addFunctor(kj::mv(onSuccess)),
524-
ioContext.addFunctor(kj::mv(onFailure)));
520+
auto getInnerPromise = [&]() -> jsg::Promise<void> {
521+
JSG_TRY(js) {
522+
return algorithm(js, kj::fwd<decltype(args)>(args)...);
523+
}
524+
JSG_CATCH(exception) {
525+
return js.rejectedPromise<void>(kj::mv(exception));
526+
}
527+
};
528+
return getInnerPromise().then(
529+
js, ioContext.addFunctor(kj::mv(onSuccess)), ioContext.addFunctor(kj::mv(onFailure)));
525530
} else {
526-
return js
527-
.tryCatch([&] { return algorithm(js, kj::fwd<decltype(args)>(args)...); },
528-
[&](jsg::Value&& exception) {
529-
return js.rejectedPromise<void>(kj::mv(exception));
530-
}).then(js, kj::mv(onSuccess), kj::mv(onFailure));
531+
auto getInnerPromise = [&]() -> jsg::Promise<void> {
532+
JSG_TRY(js) {
533+
return algorithm(js, kj::fwd<decltype(args)>(args)...);
534+
}
535+
JSG_CATCH(exception) {
536+
return js.rejectedPromise<void>(kj::mv(exception));
537+
}
538+
};
539+
return getInnerPromise().then(js, kj::mv(onSuccess), kj::mv(onFailure));
531540
}
532-
}, [&](jsg::Value&& exception) { return js.rejectedPromise<void>(kj::mv(exception)); });
541+
}
542+
JSG_CATCH(exception) {
543+
return js.rejectedPromise<void>(kj::mv(exception));
544+
}
533545
}
534546

535547
// If the algorithm does not exist, we just handle it as a success and move on.
@@ -1628,10 +1640,13 @@ jsg::Promise<void> WritableImpl<Self>::write(
16281640
size_t size = 1;
16291641
KJ_IF_SOME(sizeFunc, algorithms.size) {
16301642
kj::Maybe<jsg::Value> failure;
1631-
js.tryCatch([&] { size = sizeFunc(js, value); }, [&](jsg::Value exception) {
1643+
JSG_TRY(js) {
1644+
size = sizeFunc(js, value);
1645+
}
1646+
JSG_CATCH(exception) {
16321647
startErroring(js, self.addRef(), exception.getHandle(js));
16331648
failure = kj::mv(exception);
1634-
});
1649+
}
16351650
KJ_IF_SOME(exception, failure) {
16361651
return js.rejectedPromise<void>(kj::mv(exception));
16371652
}

src/workerd/api/urlpattern-standard.c++

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@ std::optional<URLPattern::URLPatternRegexEngine::regex_type> URLPattern::URLPatt
1919
// std::string_view is not guaranteed to be null-terminated, but kj::StringPtr requires it.
2020
// We need to create a null-terminated copy.
2121
auto str = kj::str(kj::arrayPtr(pattern.data(), pattern.size()));
22-
return js.tryCatch([&]() -> std::optional<regex_type> {
22+
JSG_TRY(js) {
2323
return jsg::JsRef(js, js.regexp(str, flags));
24-
}, [&](auto reason) -> std::optional<regex_type> { return std::nullopt; });
24+
}
25+
JSG_CATCH(_) {
26+
return std::nullopt;
27+
}
2528
}
2629

2730
bool URLPattern::URLPatternRegexEngine::regex_match(

src/workerd/api/urlpattern.c++

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@ namespace workerd::api {
1313
namespace {
1414
jsg::JsRef<jsg::JsRegExp> compileRegex(
1515
jsg::Lock& js, const jsg::UrlPattern::Component& component, bool ignoreCase) {
16-
return js.tryCatch([&] {
16+
JSG_TRY(js) {
1717
jsg::Lock::RegExpFlags flags = jsg::Lock::RegExpFlags::kUNICODE;
1818
if (ignoreCase) {
1919
flags = static_cast<jsg::Lock::RegExpFlags>(
2020
flags | static_cast<int>(jsg::Lock::RegExpFlags::kIGNORE_CASE));
2121
}
2222
return jsg::JsRef<jsg::JsRegExp>(js, js.regexp(component.getRegex(), flags));
23-
}, [&](auto reason) -> jsg::JsRef<jsg::JsRegExp> {
23+
}
24+
JSG_CATCH(_) {
2425
JSG_FAIL_REQUIRE(TypeError, "Invalid regular expression syntax.");
25-
});
26+
}
2627
}
2728

2829
jsg::Ref<URLPattern> create(jsg::Lock& js, jsg::UrlPattern pattern) {

src/workerd/jsg/README.md

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ built around them.
3737

3838
In order to execute JavaScript on the current thread, a lock must be acquired on the `v8::Isolate`.
3939
The `jsg::Lock&` represents the current lock. It is passed as an argument to many methods that
40-
require access to the JavaScript isolate and context.
40+
require access to the JavaScript isolate and context. By convention, this argument is always named
41+
`js`.
4142

4243
The `jsg::Lock` interface itself provides access to basic JavaScript functionality, such as the
4344
ability to construct basic JavaScript values and call JavaScript functions.
@@ -2534,14 +2535,11 @@ The `jsErrorType` parameter can be one of:
25342535
Unlike `KJ_REQUIRE`, `JSG_REQUIRE` passes all message arguments through `kj::str()`, so you are
25352536
responsible for formatting the entire message string.
25362537

2537-
#### `JsExceptionThrown`
2538+
#### `js.error()`, `js.throwException()`, and `JsExceptionThrown`
25382539

2539-
When C++ code needs to throw a JavaScript exception, it should:
2540-
1. Call `isolate->ThrowException()` to set the JavaScript error value
2541-
2. Throw `JsExceptionThrown()` as a C++ exception
2542-
2543-
This C++ exception is caught by JSG's callback glue before returning to V8. This approach is
2544-
more ergonomic than V8's convention of returning `v8::Maybe` values.
2540+
When C++ code needs to throw a JavaScript exception:
2541+
1. Create the error object with `js.error("Error reason")`
2542+
2. Throw using `js.throwException()`
25452543

25462544
```cpp
25472545
void someMethod(jsg::Lock& js) {
@@ -2554,6 +2552,36 @@ void someMethod(jsg::Lock& js) {
25542552
}
25552553
```
25562554
2555+
Under the hood, `js.throwException()` uses V8's lower level API, `isolate->ThrowException()`, to
2556+
throw the exception in the V8 engine. It then throws a special C++ object of type
2557+
`JsExceptionThrown`, whose purpose is to unwind the C++ stack back to the point where JavaScript
2558+
called into C++. This C++ exception is caught by JSG's callback glue before returning to V8. This
2559+
approach is more ergonomic than V8's convention of returning `v8::Maybe` values.
2560+
2561+
#### `JSG_TRY` and `JSG_CATCH`
2562+
2563+
JSG provides `JSG_TRY` and `JSG_CATCH` macros which replace the normal `try` and `catch` keywords
2564+
when you need to catch exceptions as JavaScript exceptions. Each take one argument: `JSG_TRY` takes
2565+
the `jsg::Lock&` reference, and `JSG_CATCH` takes your desired variable name for the caught
2566+
exception.
2567+
2568+
```cpp
2569+
void someMethod(jsg::Lock& js) {
2570+
JSG_TRY(js) {
2571+
someThrowyCode();
2572+
}
2573+
JSG_CATCH(e) {
2574+
// Just rethrow.
2575+
js.throwException(kj::mv(e));
2576+
}
2577+
}
2578+
```
2579+
2580+
The example above actually illustrates a common, useful scenario of coercing any exception thrown
2581+
into a JavaScript Error object. That is, if `someThrowyCode()` in the example above throws a KJ C++
2582+
exception, `JSG_CATCH(e)` will catch it and convert it to a JavaScript error by calling
2583+
`js.exceptionToJs()`.
2584+
25572585
#### `makeInternalError()` and `throwInternalError()`
25582586

25592587
These functions create JavaScript errors from internal C++ exceptions while obfuscating

src/workerd/jsg/function-test.c++

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,76 @@ struct FunctionContext: public ContextGlobalObject {
191191
});
192192
}
193193

194+
kj::String testTryCatch2(Lock& js, jsg::Function<int()> thrower) {
195+
// Here we prove that the macro is if-else friendly.
196+
if (true) JSG_TRY(js) {
197+
return kj::str(thrower(js));
198+
}
199+
JSG_CATCH(exception) {
200+
auto handle = exception.getHandle(js);
201+
return kj::str("caught: ", handle);
202+
}
203+
else {
204+
KJ_UNREACHABLE;
205+
}
206+
}
207+
208+
kj::String testTryCatchWithOptions(Lock& js, jsg::Function<void()> thrower) {
209+
// Test that JSG_CATCH can accept ExceptionToJsOptions.
210+
JSG_TRY(js) {
211+
thrower(js);
212+
return kj::str("no exception");
213+
}
214+
JSG_CATCH(exception, {.ignoreDetail = true}) {
215+
auto handle = exception.getHandle(js);
216+
return kj::str("caught with options: ", handle);
217+
}
218+
}
219+
220+
kj::String testNestedTryCatchInnerCatches(Lock& js, jsg::Function<void()> thrower) {
221+
// Test nested JSG_TRY/JSG_CATCH where inner catches, outer doesn't see exception.
222+
JSG_TRY(js) {
223+
kj::String innerResult;
224+
JSG_TRY(js) {
225+
thrower(js);
226+
innerResult = kj::str("inner: no exception");
227+
}
228+
JSG_CATCH(innerException) {
229+
innerResult = kj::str("inner caught: ", innerException.getHandle(js));
230+
}
231+
return kj::str("outer: no exception, ", innerResult);
232+
}
233+
JSG_CATCH(outerException) {
234+
return kj::str("outer caught: ", outerException.getHandle(js));
235+
}
236+
}
237+
238+
kj::String testNestedTryCatchOuterCatches(Lock& js, jsg::Function<void()> thrower) {
239+
// Test nested JSG_TRY/JSG_CATCH where inner rethrows, outer catches.
240+
JSG_TRY(js) {
241+
JSG_TRY(js) {
242+
thrower(js);
243+
return kj::str("inner: no exception");
244+
}
245+
JSG_CATCH(innerException) {
246+
// Rethrow so outer can catch
247+
js.throwException(kj::mv(innerException));
248+
}
249+
return kj::str("outer: no exception");
250+
}
251+
JSG_CATCH(outerException) {
252+
return kj::str("outer caught: ", outerException.getHandle(js));
253+
}
254+
}
255+
194256
JSG_RESOURCE_TYPE(FunctionContext) {
195257
JSG_METHOD(test);
196258
JSG_METHOD(test2);
197259
JSG_METHOD(testTryCatch);
260+
JSG_METHOD(testTryCatch2);
261+
JSG_METHOD(testTryCatchWithOptions);
262+
JSG_METHOD(testNestedTryCatchInnerCatches);
263+
JSG_METHOD(testNestedTryCatchOuterCatches);
198264

199265
JSG_READONLY_PROTOTYPE_PROPERTY(square, getSquare);
200266
JSG_READONLY_PROTOTYPE_PROPERTY(gcLambda, getGcLambda);
@@ -220,6 +286,57 @@ KJ_TEST("jsg::Function<T>") {
220286

221287
e.expectEval("testTryCatch(() => { return 123; })", "string", "123");
222288
e.expectEval("testTryCatch(() => { throw new Error('foo'); })", "string", "caught: Error: foo");
289+
290+
e.expectEval("testTryCatch2(() => { return 123; })", "string", "123");
291+
e.expectEval("testTryCatch2(() => { throw new Error('foo'); })", "string", "caught: Error: foo");
292+
293+
e.expectEval("testTryCatchWithOptions(() => {})", "string", "no exception");
294+
e.expectEval("testTryCatchWithOptions(() => { throw new Error('bar'); })", "string",
295+
"caught with options: Error: bar");
296+
297+
// Nested JSG_TRY/JSG_CATCH tests
298+
e.expectEval("testNestedTryCatchInnerCatches(() => {})", "string",
299+
"outer: no exception, inner: no exception");
300+
e.expectEval("testNestedTryCatchInnerCatches(() => { throw new Error('inner'); })", "string",
301+
"outer: no exception, inner caught: Error: inner");
302+
303+
e.expectEval("testNestedTryCatchOuterCatches(() => {})", "string", "inner: no exception");
304+
e.expectEval("testNestedTryCatchOuterCatches(() => { throw new Error('rethrown'); })", "string",
305+
"outer caught: Error: rethrown");
306+
}
307+
308+
KJ_TEST("JSG_TRY/JSG_CATCH with TerminateExecution") {
309+
Evaluator<FunctionContext, FunctionIsolate> e(v8System);
310+
311+
// TerminateExecution should propagate through JSG_CATCH without being caught.
312+
// The Evaluator's run() method will detect the termination and throw.
313+
KJ_EXPECT_THROW_MESSAGE("TerminateExecution() was called", e.run([](auto& js) {
314+
// Test single-level JSG_TRY/JSG_CATCH with TerminateExecution
315+
JSG_TRY(js) {
316+
js.terminateExecutionNow();
317+
}
318+
JSG_CATCH(exception) {
319+
(void)exception;
320+
KJ_FAIL_ASSERT("TerminateExecution was caught by JSG_CATCH");
321+
}
322+
}));
323+
324+
KJ_EXPECT_THROW_MESSAGE("TerminateExecution() was called", e.run([](auto& js) {
325+
// Test nested JSG_TRY/JSG_CATCH with TerminateExecution - should propagate through both
326+
JSG_TRY(js) {
327+
JSG_TRY(js) {
328+
js.terminateExecutionNow();
329+
}
330+
JSG_CATCH(innerException) {
331+
(void)innerException;
332+
KJ_FAIL_ASSERT("TerminateExecution was caught by inner JSG_CATCH");
333+
}
334+
}
335+
JSG_CATCH(outerException) {
336+
(void)outerException;
337+
KJ_FAIL_ASSERT("TerminateExecution was caught by outer JSG_CATCH");
338+
}
339+
}));
223340
}
224341

225342
} // namespace

0 commit comments

Comments
 (0)