From 98fd6db520dffe533046a26cbb2d0a1085671599 Mon Sep 17 00:00:00 2001 From: Anna Kapuscinska Date: Thu, 26 Mar 2026 23:52:54 +0000 Subject: [PATCH 1/2] Add isDynamicDispatch flag to the worker entrypoint dispatch path Pass isDynamicDispatch bool from newWorkerEntrypoint() into getExportedHandler(), covering both the HTTP/connect path (via WorkerEntrypoint) and the JS RPC path (via JsRpcSessionCustomEvent and EntrypointJsRpcTarget). The flag is passed as a parameter to CustomEvent::run() so every implementation is required at the call site to forward it explicitly to getExportedHandler(). All other CustomEvent::run() implementations (trace, queue, hibernatable-web-socket, tail-stream) also pass it through for consistency and forward-compatibility. --- src/workerd/api/hibernatable-web-socket.c++ | 22 ++++++++------- src/workerd/api/hibernatable-web-socket.h | 3 +- src/workerd/api/queue.c++ | 9 +++--- src/workerd/api/queue.h | 3 +- src/workerd/api/trace.c++ | 17 ++++++----- src/workerd/api/trace.h | 3 +- src/workerd/api/worker-rpc.c++ | 14 ++++++---- src/workerd/api/worker-rpc.h | 3 +- src/workerd/io/trace-stream.c++ | 19 ++++++++----- src/workerd/io/trace-stream.h | 3 +- src/workerd/io/worker-entrypoint.c++ | 31 +++++++++++++-------- src/workerd/io/worker-entrypoint.h | 3 +- src/workerd/io/worker-interface.h | 3 +- src/workerd/io/worker.c++ | 3 +- src/workerd/io/worker.h | 8 +++++- 15 files changed, 90 insertions(+), 54 deletions(-) diff --git a/src/workerd/api/hibernatable-web-socket.c++ b/src/workerd/api/hibernatable-web-socket.c++ index 564f1493017..b7606c61c43 100644 --- a/src/workerd/api/hibernatable-web-socket.c++ +++ b/src/workerd/api/hibernatable-web-socket.c++ @@ -62,7 +62,8 @@ kj::Promise HibernatableWebSocketCustomEve kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { // Mark the request as delivered because we're about to run some JS. auto& context = incomingRequest->getContext(); incomingRequest->delivered(); @@ -83,33 +84,34 @@ kj::Promise HibernatableWebSocketCustomEve try { co_await context.run( [entrypointName = entrypointName, &context, eventParameters = kj::mv(eventParameters), - versionInfo = kj::mv(versionInfo), props = kj::mv(props)](Worker::Lock& lock) mutable { + versionInfo = kj::mv(versionInfo), props = kj::mv(props), + isDynamicDispatch](Worker::Lock& lock) mutable { KJ_SWITCH_ONEOF(eventParameters.eventType) { KJ_CASE_ONEOF(text, HibernatableSocketParams::Text) { return lock.getGlobalScope().sendHibernatableWebSocketMessage(context, kj::mv(text.message), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_CASE_ONEOF(data, HibernatableSocketParams::Data) { return lock.getGlobalScope().sendHibernatableWebSocketMessage(context, kj::mv(data.message), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_CASE_ONEOF(close, HibernatableSocketParams::Close) { return lock.getGlobalScope().sendHibernatableWebSocketClose(context, kj::mv(close), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_CASE_ONEOF(e, HibernatableSocketParams::Error) { return lock.getGlobalScope().sendHibernatableWebSocketError(context, kj::mv(e.error), eventParameters.eventTimeoutMs, kj::mv(eventParameters.websocketId), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); } KJ_UNREACHABLE; } diff --git a/src/workerd/api/hibernatable-web-socket.h b/src/workerd/api/hibernatable-web-socket.h index 84da6c6d9db..f17b817bc57 100644 --- a/src/workerd/api/hibernatable-web-socket.h +++ b/src/workerd/api/hibernatable-web-socket.h @@ -68,7 +68,8 @@ class HibernatableWebSocketCustomEvent final: public WorkerInterface::CustomEven kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/api/queue.c++ b/src/workerd/api/queue.c++ index f4a8846cb44..c9191953521 100644 --- a/src/workerd/api/queue.c++ +++ b/src/workerd/api/queue.c++ @@ -825,7 +825,8 @@ kj::Promise QueueCustomEvent::run( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { // This method has three main chunks of logic: // 1. Do all necessary setup work. This starts right below this comment. // 2. Call into the worker's queue event handler. @@ -846,14 +847,14 @@ kj::Promise QueueCustomEvent::run( auto runProm = context.run( [this, entrypointName = entrypointName, &context, queueEvent = kj::addRef(*queueEventHolder), &metrics = incomingRequest->getMetrics(), versionInfo = kj::mv(versionInfo), - props = kj::mv(props)](Worker::Lock& lock) mutable { + props = kj::mv(props), isDynamicDispatch](Worker::Lock& lock) mutable { jsg::AsyncContextFrame::StorageScope traceScope = context.makeAsyncTraceScope(lock); auto& typeHandler = lock.getWorker().getIsolate().getApi().getQueueTypeHandler(lock); auto startResp = startQueueEvent(lock.getGlobalScope(), context, kj::mv(params), context.addObject(result), lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor()), + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch), typeHandler); queueEvent->event = kj::mv(startResp.event); queueEvent->exportedHandlerProm = kj::mv(startResp.exportedHandlerProm); diff --git a/src/workerd/api/queue.h b/src/workerd/api/queue.h index d2543ecb3d3..781a5eaef99 100644 --- a/src/workerd/api/queue.h +++ b/src/workerd/api/queue.h @@ -473,7 +473,8 @@ class QueueCustomEvent final: public WorkerInterface::CustomEvent, public kj::Re kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/api/trace.c++ b/src/workerd/api/trace.c++ index d5ecb2fbeed..585a212a5e5 100644 --- a/src/workerd/api/trace.c++ +++ b/src/workerd/api/trace.c++ @@ -651,7 +651,8 @@ kj::Promise sendTracesToExportedHandler(kj::Own entrypointNamePtr, kj::Maybe versionInfo, Frankenvalue props, - kj::ArrayPtr> traces) { + kj::ArrayPtr> traces, + bool isDynamicDispatch) { // Mark the request as delivered because we're about to run some JS. incomingRequest->delivered(); @@ -672,11 +673,12 @@ kj::Promise sendTracesToExportedHandler(kj::Own incomingRequest, kj::Maybe entrypointNamePtr, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) -> kj::Promise { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) -> kj::Promise { // Don't bother to wait around for the handler to run, just hand it off to the waitUntil tasks. - waitUntilTasks.add(sendTracesToExportedHandler( - kj::mv(incomingRequest), entrypointNamePtr, kj::mv(versionInfo), kj::mv(props), traces)); + waitUntilTasks.add(sendTracesToExportedHandler(kj::mv(incomingRequest), entrypointNamePtr, + kj::mv(versionInfo), kj::mv(props), traces, isDynamicDispatch)); // Reporting a proper outcome and return event here would be nice, but for that we'd need to await // running the tail handler... diff --git a/src/workerd/api/trace.h b/src/workerd/api/trace.h index eb47438459c..a8258e72e70 100644 --- a/src/workerd/api/trace.h +++ b/src/workerd/api/trace.h @@ -655,7 +655,8 @@ class TraceCustomEvent final: public WorkerInterface::CustomEvent { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/api/worker-rpc.c++ b/src/workerd/api/worker-rpc.c++ index 17bf9f3a261..ace2c41a905 100644 --- a/src/workerd/api/worker-rpc.c++ +++ b/src/workerd/api/worker-rpc.c++ @@ -1982,7 +1982,8 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { kj::Maybe versionInfo, Frankenvalue props, kj::Maybe wrapperModule, - kj::Maybe> tracer) + kj::Maybe> tracer, + bool isDynamicDispatch) : JsRpcTargetBase(ioCtx, CantOutliveIncomingRequest()), ioCtx(ioCtx), // Most of the time we don't really have to clone this but it's hard to fully prove, so @@ -1991,7 +1992,8 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { versionInfo(kj::mv(versionInfo)), props(kj::mv(props)), wrapperModule(kj::mv(wrapperModule)), - tracer(kj::mv(tracer)) {} + tracer(kj::mv(tracer)), + isDynamicDispatch(isDynamicDispatch) {} // Override call() to emit the Return event when the top-level RPC call completes. // This marks when the handler returned a value, NOT when all data has been streamed or all @@ -2008,7 +2010,7 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { jsg::Lock& js = lock; auto handler = KJ_REQUIRE_NONNULL(lock.getExportedHandler(entrypointName, kj::mv(versionInfo), - kj::mv(props), ioCtx.getActor()), + kj::mv(props), ioCtx.getActor(), isDynamicDispatch), "Failed to get handler to worker."); if (handler->missingSuperclass && wrapperModule == kj::none) { @@ -2077,6 +2079,7 @@ class EntrypointJsRpcTarget final: public JsRpcTargetBase { Frankenvalue props; kj::Maybe wrapperModule; kj::Maybe> tracer; + bool isDynamicDispatch; bool isReservedName(kj::StringPtr name) override { if ( // "fetch" and "connect" are treated specially on entrypoints. @@ -2165,7 +2168,8 @@ kj::Promise JsRpcSessionCustomEvent::run( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { IoContext& ioctx = incomingRequest->getContext(); incomingRequest->delivered(); @@ -2177,7 +2181,7 @@ kj::Promise JsRpcSessionCustomEvent::run( }); EntrypointJsRpcTarget target(ioctx, entrypointName, kj::mv(versionInfo), kj::mv(props), - kj::mv(wrapperModule), mapAddRef(incomingRequest->getWorkerTracer())); + kj::mv(wrapperModule), mapAddRef(incomingRequest->getWorkerTracer()), isDynamicDispatch); capnp::RevocableServer revcableTarget(target); try { diff --git a/src/workerd/api/worker-rpc.h b/src/workerd/api/worker-rpc.h index 263d686cda6..c5db58bbb2d 100644 --- a/src/workerd/api/worker-rpc.h +++ b/src/workerd/api/worker-rpc.h @@ -476,7 +476,8 @@ class JsRpcSessionCustomEvent final: public WorkerInterface::CustomEvent { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/io/trace-stream.c++ b/src/workerd/io/trace-stream.c++ index cb7dcae0dae..a3716a15ddf 100644 --- a/src/workerd/io/trace-stream.c++ +++ b/src/workerd/io/trace-stream.c++ @@ -620,12 +620,14 @@ class TailStreamTarget final: public rpc::TailStreamTarget::Server { kj::Maybe entrypointNamePtr, kj::Maybe versionInfo, Frankenvalue props, - kj::Own> doneFulfiller) + kj::Own> doneFulfiller, + bool isDynamicDispatch) : weakIoContext(ioContext.getWeakRef()), entrypointNamePtr(kj::mv(entrypointNamePtr)), versionInfo(kj::mv(versionInfo)), props(kj::mv(props)), - doneFulfiller(kj::mv(doneFulfiller)) {} + doneFulfiller(kj::mv(doneFulfiller)), + isDynamicDispatch(isDynamicDispatch) {} KJ_DISALLOW_COPY_AND_MOVE(TailStreamTarget); ~TailStreamTarget() { @@ -735,9 +737,10 @@ class TailStreamTarget final: public rpc::TailStreamTarget::Server { events.size() == 1 && events[0].event.is(), "Expected only a single onset event"); auto& event = events[0]; - auto handler = KJ_REQUIRE_NONNULL(lock.getExportedHandler(entrypointNamePtr, - kj::mv(versionInfo), kj::mv(props), ioContext.getActor()), - "Failed to get handler to worker."); + auto handler = + KJ_REQUIRE_NONNULL(lock.getExportedHandler(entrypointNamePtr, kj::mv(versionInfo), + kj::mv(props), ioContext.getActor(), isDynamicDispatch), + "Failed to get handler to worker."); StringCache stringCache; jsg::Lock& js = lock; @@ -934,6 +937,7 @@ class TailStreamTarget final: public rpc::TailStreamTarget::Server { // or rejected if the capability is dropped before receiving the outcome // event. kj::Own> doneFulfiller; + bool isDynamicDispatch; // The maybeHandler will be empty until we receive and process the // onset event. @@ -954,13 +958,14 @@ kj::Promise TailStreamCustomEvent::run( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) { + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) { IoContext& ioContext = incomingRequest->getContext(); incomingRequest->delivered(); auto [donePromise, doneFulfiller] = kj::newPromiseAndFulfiller(); capFulfiller->fulfill(kj::heap(ioContext, kj::mv(entrypointName), - kj::mv(versionInfo), kj::mv(props), kj::mv(doneFulfiller))); + kj::mv(versionInfo), kj::mv(props), kj::mv(doneFulfiller), isDynamicDispatch)); donePromise = donePromise.attach(ioContext.registerPendingEvent()); diff --git a/src/workerd/io/trace-stream.h b/src/workerd/io/trace-stream.h index 7d76c187dbf..4260887bf2f 100644 --- a/src/workerd/io/trace-stream.h +++ b/src/workerd/io/trace-stream.h @@ -30,7 +30,8 @@ class TailStreamCustomEvent final: public WorkerInterface::CustomEvent { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) override; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch) override; kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, capnp::ByteStreamFactory& byteStreamFactory, diff --git a/src/workerd/io/worker-entrypoint.c++ b/src/workerd/io/worker-entrypoint.c++ index 17fc33247a7..e7371cddac3 100644 --- a/src/workerd/io/worker-entrypoint.c++ +++ b/src/workerd/io/worker-entrypoint.c++ @@ -60,7 +60,8 @@ class WorkerEntrypoint final: public WorkerInterface { kj::Maybe> workerTracer, kj::Maybe cfBlobJson, kj::Maybe versionInfo, - kj::Maybe maybeTriggerInvocationSpan); + kj::Maybe maybeTriggerInvocationSpan, + bool isDynamicDispatch); kj::Promise request(kj::HttpMethod method, kj::StringPtr url, @@ -87,6 +88,7 @@ class WorkerEntrypoint final: public WorkerInterface { kj::TaskSet& waitUntilTasks; kj::Maybe> incomingRequest; bool tunnelExceptions; + bool isDynamicDispatch; kj::Maybe entrypointName; Frankenvalue props; kj::Maybe cfBlobJson; @@ -122,6 +124,7 @@ class WorkerEntrypoint final: public WorkerInterface { ThreadContext& threadContext, kj::TaskSet& waitUntilTasks, bool tunnelExceptions, + bool isDynamicDispatch, kj::Maybe entrypointName, Frankenvalue props, kj::Maybe cfBlobJson, @@ -179,12 +182,13 @@ kj::Own WorkerEntrypoint::construct(ThreadContext& threadContex kj::Maybe> workerTracer, kj::Maybe cfBlobJson, kj::Maybe versionInfo, - kj::Maybe maybeTriggerInvocationSpan) { + kj::Maybe maybeTriggerInvocationSpan, + bool isDynamicDispatch) { TRACE_EVENT("workerd", "WorkerEntrypoint::construct()"); - auto obj = - kj::heap(kj::Badge(), threadContext, waitUntilTasks, - tunnelExceptions, entrypointName, kj::mv(props), kj::mv(cfBlobJson), kj::mv(versionInfo)); + auto obj = kj::heap(kj::Badge(), threadContext, + waitUntilTasks, tunnelExceptions, isDynamicDispatch, entrypointName, kj::mv(props), + kj::mv(cfBlobJson), kj::mv(versionInfo)); obj->init(kj::mv(worker), kj::mv(actor), kj::mv(limitEnforcer), kj::mv(ioContextDependency), kj::mv(ioChannelFactory), kj::addRef(*metrics), kj::mv(workerTracer), kj::mv(maybeTriggerInvocationSpan)); @@ -196,6 +200,7 @@ WorkerEntrypoint::WorkerEntrypoint(kj::Badge badge, ThreadContext& threadContext, kj::TaskSet& waitUntilTasks, bool tunnelExceptions, + bool isDynamicDispatch, kj::Maybe entrypointName, Frankenvalue props, kj::Maybe cfBlobJson, @@ -203,6 +208,7 @@ WorkerEntrypoint::WorkerEntrypoint(kj::Badge badge, : threadContext(threadContext), waitUntilTasks(waitUntilTasks), tunnelExceptions(tunnelExceptions), + isDynamicDispatch(isDynamicDispatch), entrypointName(entrypointName), props(kj::mv(props)), cfBlobJson(kj::mv(cfBlobJson)), @@ -352,8 +358,8 @@ kj::Promise WorkerEntrypoint::request(kj::HttpMethod method, return lock.getGlobalScope().request(method, url, headers, requestBody, wrappedResponse, cfBlobJson, lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor()), + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch), kj::mv(signal)); }) .then([this, &context, &wrappedResponse = *wrappedResponse, workerTracer]( @@ -587,8 +593,8 @@ kj::Promise WorkerEntrypoint::connect(kj::StringPtr host, jsg::AsyncContextFrame::StorageScope traceScope = context.makeAsyncTraceScope(lock); return lock.getGlobalScope().connect(kj::mv(host), headers, connection, response, lock, - lock.getExportedHandler( - entrypointName, kj::mv(versionInfo), kj::mv(props), context.getActor())); + lock.getExportedHandler(entrypointName, kj::mv(versionInfo), kj::mv(props), + context.getActor(), isDynamicDispatch)); }) .then([&context, workerTracer]() { KJ_IF_SOME(t, workerTracer) { @@ -919,7 +925,7 @@ kj::Promise WorkerEntrypoint::customEvent( auto promise = event ->run(kj::mv(incomingRequest), entrypointName, kj::mv(versionInfo), - kj::mv(props), waitUntilTasks) + kj::mv(props), waitUntilTasks, isDynamicDispatch) .attach(kj::mv(event)); // TODO(cleanup): In theory `context` may have been destroyed by now if `event->run()` dropped @@ -981,12 +987,13 @@ kj::Own newWorkerEntrypoint(ThreadContext& threadContext, kj::Maybe> workerTracer, kj::Maybe cfBlobJson, kj::Maybe versionInfo, - kj::Maybe maybeTriggerInvocationSpan) { + kj::Maybe maybeTriggerInvocationSpan, + bool isDynamicDispatch) { return WorkerEntrypoint::construct(threadContext, kj::mv(worker), kj::mv(entrypointName), kj::mv(props), kj::mv(actor), kj::mv(limitEnforcer), kj::mv(ioContextDependency), kj::mv(ioChannelFactory), kj::mv(metrics), waitUntilTasks, tunnelExceptions, kj::mv(workerTracer), kj::mv(cfBlobJson), kj::mv(versionInfo), - kj::mv(maybeTriggerInvocationSpan)); + kj::mv(maybeTriggerInvocationSpan), isDynamicDispatch); } } // namespace workerd diff --git a/src/workerd/io/worker-entrypoint.h b/src/workerd/io/worker-entrypoint.h index 7bf41b1a06f..e7ed1c1dfa8 100644 --- a/src/workerd/io/worker-entrypoint.h +++ b/src/workerd/io/worker-entrypoint.h @@ -46,6 +46,7 @@ kj::Own newWorkerEntrypoint(ThreadContext& threadContext, // the implication is that this worker entrypoint is being created as a subrequest or // subtask of another request. If it is kj::none, then this invocation is a top-level // invocation. - kj::Maybe maybeTriggerInvocationSpan = kj::none); + kj::Maybe maybeTriggerInvocationSpan = kj::none, + bool isDynamicDispatch = false); } // namespace workerd diff --git a/src/workerd/io/worker-interface.h b/src/workerd/io/worker-interface.h index 00868c9462b..32cb3bf77f4 100644 --- a/src/workerd/io/worker-interface.h +++ b/src/workerd/io/worker-interface.h @@ -131,7 +131,8 @@ class WorkerInterface: public kj::HttpService { kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::TaskSet& waitUntilTasks) = 0; + kj::TaskSet& waitUntilTasks, + bool isDynamicDispatch = false) = 0; // Forward the event over RPC. virtual kj::Promise sendRpc(capnp::HttpOverCapnpFactory& httpOverCapnpFactory, diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 530b4c9dc9d..78c944b6871 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -2318,7 +2318,8 @@ kj::Maybe> Worker::Lock::getExportedHandler( kj::Maybe name, kj::Maybe versionInfo, Frankenvalue props, - kj::Maybe actor) { + kj::Maybe actor, + bool isDynamicDispatch) { KJ_IF_SOME(a, actor) { KJ_IF_SOME(h, a.getHandler()) { return fakeOwn(h); diff --git a/src/workerd/io/worker.h b/src/workerd/io/worker.h index f0301b5a794..31da3c54374 100644 --- a/src/workerd/io/worker.h +++ b/src/workerd/io/worker.h @@ -758,11 +758,17 @@ class Worker::Lock { // // If running in an actor, the name and props are ignored and the entrypoint originally used to // construct the actor is returned. + // + // `isDynamicDispatch` indicates the entrypoint name was supplied at request time (e.g. by the + // Workflows engine via dynamic dispatch) rather than baked into the pipeline at config time. When + // true, a missing entrypoint is surfaced as a JSG TypeError to the caller rather than being + // logged as an internal error, since the mismatch is attributable to user configuration. kj::Maybe> getExportedHandler( kj::Maybe entrypointName, kj::Maybe versionInfo, Frankenvalue props, - kj::Maybe actor); + kj::Maybe actor, + bool isDynamicDispatch = false); // Get the C++ object representing the global scope. api::ServiceWorkerGlobalScope& getGlobalScope(); From df79103254fe1bf1bfee8edbf168fd354b475c74 Mon Sep 17 00:00:00 2001 From: Anna Kapuscinska Date: Thu, 26 Mar 2026 23:53:12 +0000 Subject: [PATCH 2/2] Reclassify entrypoint-not-found errors as JSG errors for dynamic dispatch When isDynamicDispatch is true (entrypoint name supplied at request time), two errors in getExportedHandler() are reclassified as JSG TypeErrors so they surface to the caller rather than being logged as internal errors. This will inform users about their likely misconfiguration: - A DO class name requested via the non-actor dispatch path - A non-existent entrypoint name For static dispatch (isDynamicDispatch false) both cases retain their existing LOG_ERROR_PERIODICALLY behaviour, since they can reflect genuine bugs or infrastructure failures. --- src/workerd/io/BUILD.bazel | 8 ++ .../io/worker-getexportedhandler-test.c++ | 95 +++++++++++++++++++ src/workerd/io/worker.c++ | 11 ++- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/workerd/io/worker-getexportedhandler-test.c++ diff --git a/src/workerd/io/BUILD.bazel b/src/workerd/io/BUILD.bazel index 669d3e827f7..978f9d96728 100644 --- a/src/workerd/io/BUILD.bazel +++ b/src/workerd/io/BUILD.bazel @@ -526,3 +526,11 @@ kj_test( "//src/workerd/tests:test-fixture", ], ) + +kj_test( + src = "worker-getexportedhandler-test.c++", + deps = [ + ":io", + "//src/workerd/tests:test-fixture", + ], +) diff --git a/src/workerd/io/worker-getexportedhandler-test.c++ b/src/workerd/io/worker-getexportedhandler-test.c++ new file mode 100644 index 00000000000..be86a383844 --- /dev/null +++ b/src/workerd/io/worker-getexportedhandler-test.c++ @@ -0,0 +1,95 @@ +// Copyright (c) 2024 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +// Tests for Worker::Lock::getExportedHandler() error behaviour, specifically +// the isDynamicDispatch path which surfaces user-configuration mistakes as JSG +// TypeErrors rather than internal log-only errors. + +#include +#include +#include +#include + +#include + +namespace workerd { +namespace { + +// --------------------------------------------------------------------------- +// isDynamicDispatch = true: both error cases must throw a JSG TypeError. +// --------------------------------------------------------------------------- + +KJ_TEST("getExportedHandler: DO class via dynamic dispatch throws JSG TypeError") { + TestFixture fixture({ + .mainModuleSource = R"( + import { DurableObject } from "cloudflare:workers"; + export class SomeActor extends DurableObject {} + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + KJ_EXPECT_THROW_MESSAGE( + "jsg.TypeError: The entrypoint name SomeActor refers to a Durable Object class, but the " + "incoming request is trying to invoke it as a stateless worker.", + env.lock.getExportedHandler("SomeActor"_kj, kj::none, Frankenvalue{}, kj::none, true)); + }); +} + +KJ_TEST("getExportedHandler: missing entrypoint via dynamic dispatch throws JSG TypeError") { + TestFixture fixture({ + .mainModuleSource = R"( + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + KJ_EXPECT_THROW_MESSAGE( + "jsg.TypeError: The entrypoint name nonExistent was not found in this worker. Ensure the " + "worker exports an entrypoint with that name.", + env.lock.getExportedHandler("nonExistent"_kj, kj::none, Frankenvalue{}, kj::none, true)); + }); +} + +// --------------------------------------------------------------------------- +// isDynamicDispatch = false: both error cases must NOT throw a JSG TypeError +// (they log and then throw a non-JSG internal error via KJ_FAIL_ASSERT). +// --------------------------------------------------------------------------- + +KJ_TEST("getExportedHandler: DO class via static dispatch throws internal error") { + TestFixture fixture({ + .mainModuleSource = R"( + import { DurableObject } from "cloudflare:workers"; + export class SomeActor extends DurableObject {} + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + // LOG_ERROR_PERIODICALLY fires, then KJ_FAIL_ASSERT throws. + // No JSG TypeError — the error is treated as internal. + KJ_EXPECT_LOG(ERROR, "worker is not an actor but class name was requested; n = SomeActor"); + KJ_EXPECT_THROW_MESSAGE("worker_do_not_log; Unable to get exported handler", + env.lock.getExportedHandler("SomeActor"_kj, kj::none, Frankenvalue{}, kj::none, false)); + }); +} + +KJ_TEST("getExportedHandler: missing entrypoint via static dispatch throws internal error") { + TestFixture fixture({ + .mainModuleSource = R"( + export default { async fetch(req) { return new Response("ok"); } } + )"_kj, + }); + + fixture.runInIoContext([&](const TestFixture::Environment& env) { + // LOG_ERROR_PERIODICALLY fires, then KJ_FAIL_ASSERT throws. + // No JSG TypeError — the error is treated as internal. + KJ_EXPECT_LOG(ERROR, "worker has no such named entrypoint; n = nonExistent"); + KJ_EXPECT_THROW_MESSAGE("worker_do_not_log; Unable to get exported handler", + env.lock.getExportedHandler("nonExistent"_kj, kj::none, Frankenvalue{}, kj::none, false)); + }); +} + +} // namespace +} // namespace workerd diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 78c944b6871..7da8f7f4670 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -2371,7 +2371,16 @@ kj::Maybe> Worker::Lock::getExportedHandler( return kj::none; } else { if (worker.impl->actorClasses.find(n) != kj::none) { - LOG_ERROR_PERIODICALLY("worker is not an actor but class name was requested", n); + if (isDynamicDispatch) { + JSG_FAIL_REQUIRE(TypeError, "The entrypoint name ", n, + " refers to a Durable Object class, but the incoming request is trying to invoke it as" + " a stateless worker."); + } else { + LOG_ERROR_PERIODICALLY("worker is not an actor but class name was requested", n); + } + } else if (isDynamicDispatch) { + JSG_FAIL_REQUIRE(TypeError, "The entrypoint name ", n, + " was not found in this worker. Ensure the worker exports an entrypoint with that name."); } else { LOG_ERROR_PERIODICALLY("worker has no such named entrypoint", n); }