From 6c40c4f83012d44e2f3d2a3ddc8ecc03e244c2b3 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 8 Oct 2024 16:56:05 -0700 Subject: [PATCH] Implement process.exit(...) in the runtime --- src/node/internal/process.ts | 5 ++ src/node/internal/util.d.ts | 1 + src/workerd/api/node/BUILD.bazel | 7 +++ .../api/node/tests/process-exit-test.js | 63 +++++++++++++++++++ .../api/node/tests/process-exit-test.wd-test | 23 +++++++ src/workerd/api/node/util.c++ | 49 +++++++++++++++ src/workerd/api/node/util.h | 8 +++ 7 files changed, 156 insertions(+) create mode 100644 src/workerd/api/node/tests/process-exit-test.js create mode 100644 src/workerd/api/node/tests/process-exit-test.wd-test diff --git a/src/node/internal/process.ts b/src/node/internal/process.ts index 5aa13bc36b6..c73f6b2bbea 100644 --- a/src/node/internal/process.ts +++ b/src/node/internal/process.ts @@ -88,8 +88,13 @@ export function getBuiltinModule(id: string): any { return utilImpl.getBuiltinModule(id); } +export function exit(code: number) { + utilImpl.processExitImpl(code); +} + export default { nextTick, env, + exit, getBuiltinModule, }; diff --git a/src/node/internal/util.d.ts b/src/node/internal/util.d.ts index 573026a49ab..d222b0e9d81 100644 --- a/src/node/internal/util.d.ts +++ b/src/node/internal/util.d.ts @@ -122,3 +122,4 @@ export function isBoxedPrimitive( export function getBuiltinModule(id: string): any; export function getCallSite(frames: number): Record[]; +export function processExitImpl(code: number): void; diff --git a/src/workerd/api/node/BUILD.bazel b/src/workerd/api/node/BUILD.bazel index 33aa9c94706..e212bddace6 100644 --- a/src/workerd/api/node/BUILD.bazel +++ b/src/workerd/api/node/BUILD.bazel @@ -54,6 +54,7 @@ wd_cc_library( ], visibility = ["//visibility:public"], deps = [ + "//src/workerd/io", "//src/workerd/io:compatibility-date_capnp", "//src/workerd/jsg", "//src/workerd/util:mimetype", @@ -207,3 +208,9 @@ wd_test( args = ["--experimental"], data = ["tests/module-create-require-test.js"], ) + +wd_test( + src = "tests/process-exit-test.wd-test", + args = ["--experimental"], + data = ["tests/process-exit-test.js"], +) diff --git a/src/workerd/api/node/tests/process-exit-test.js b/src/workerd/api/node/tests/process-exit-test.js new file mode 100644 index 00000000000..c7417f62f9e --- /dev/null +++ b/src/workerd/api/node/tests/process-exit-test.js @@ -0,0 +1,63 @@ +import { fail, ok, rejects, strictEqual } from 'assert'; + +let called = false; + +process.exit(9999); + +export const test = { + async test(_, env) { + // We don't really have a way to verifying that the correct log messages + // we emitted. For now, we need to manually check the log out, and here + // we check only that we received an internal error and that no other + // lines of code ran after the process.exit call. + await rejects(env.subrequest.fetch('http://example.org'), { + message: /^The Node.js process.exit/, + }); + ok(!called); + }, +}; + +let fooCreateCount = 0; + +export const test2 = { + async test(_, env) { + // We don't really have a way to verifying that the correct log messages + // we emitted. For now, we need to manually check the log out, and here + // we check only that we received an internal error and that no other + // lines of code ran after the process.exit call. + { + const obj = env.foo.get( + env.foo.idFromName('210bd0cbd803ef7883a1ee9d86cce06f') + ); + await rejects(obj.fetch('http://example.org'), { + message: /^The Node.js process.exit/, + }); + await rejects(obj.fetch('http://example.org'), { + message: /^The Node.js process.exit/, + }); + // The durable object should have been created twice. + strictEqual(fooCreateCount, 2); + } + }, +}; + +export default { + async fetch() { + try { + process.exit(123); + } finally { + called = true; + } + }, +}; + +export class Foo { + constructor() { + fooCreateCount++; + } + + fetch() { + process.exit(123); + fail('Should never get here.'); + } +} diff --git a/src/workerd/api/node/tests/process-exit-test.wd-test b/src/workerd/api/node/tests/process-exit-test.wd-test new file mode 100644 index 00000000000..120cc318166 --- /dev/null +++ b/src/workerd/api/node/tests/process-exit-test.wd-test @@ -0,0 +1,23 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "process-exit-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "process-exit-test.js") + ], + compatibilityDate = "2024-10-01", + compatibilityFlags = ["nodejs_compat"], + durableObjectNamespaces = [ + (className = "Foo", uniqueKey = "210bd0cbd803ef7883a1ee9d86cce06f"), + ], + durableObjectStorage = (inMemory = void), + bindings = [ + (name = "subrequest", service = "process-exit-test"), + (name = "foo", durableObjectNamespace = "Foo"), + ] + ) + ), + ], +); diff --git a/src/workerd/api/node/util.c++ b/src/workerd/api/node/util.c++ index 2dc1b1b0d14..5fe83665f23 100644 --- a/src/workerd/api/node/util.c++ +++ b/src/workerd/api/node/util.c++ @@ -3,6 +3,7 @@ // https://opensource.org/licenses/Apache-2.0 #include "util.h" +#include #include #include @@ -240,4 +241,52 @@ jsg::JsValue UtilModule::getBuiltinModule(jsg::Lock& js, kj::String specifier) { return js.undefined(); } +namespace { +[[noreturn]] void handleProcessExit(jsg::Lock& js, int code) { + // There are a few things happening here. First, we abort the current IoContext + // in order to shut down this specific request.... + auto message = + kj::str("The Node.js process.exit(", code, ") API was called. Canceling the request."); + auto& ioContext = IoContext::current(); + // If we have a tail worker, let's report the error. + KJ_IF_SOME(tracer, ioContext.getWorkerTracer()) { + // Why create the error like this in tracing? Because we're adding the exception + // to the trace and ideally we'd have the JS stack attached to it. Just using + // JSG_KJ_EXCEPTION would not give us that, and we only want to incur the cost + // of creating and capturing the stack when we actually need it. + auto ex = KJ_ASSERT_NONNULL(js.error(message).tryCast()); + tracer.addException(ioContext.now(), ex.get(js, "name"_kj).toString(js), + ex.get(js, "message"_kj).toString(js), ex.get(js, "stack"_kj).toString(js)); + ioContext.abort(js.exceptionToKj(ex)); + } else { + ioContext.abort(JSG_KJ_EXCEPTION(FAILED, Error, kj::mv(message))); + } + // ...then we tell the isolate to terminate the current JavaScript execution. + // Oddly however, this does not appear to *actually* terminate the thread of + // execution unless we trigger the Isolate to handle the intercepts, which + // calling v8::JSON::Stringify does. Weird... but ok? As long as it works + // TODO(soon): Investigate if there is a better approach to triggering the + // interrupt handling. + js.v8Isolate->TerminateExecution(); + jsg::check(v8::JSON::Stringify(js.v8Context(), js.str())); + // This should be unreachable here as we expect the isolate to terminate and + // an exception to have been thrown. + KJ_UNREACHABLE; +} +} // namespace + +void UtilModule::processExitImpl(jsg::Lock& js, int code) { + if (IoContext::hasCurrent()) { + handleProcessExit(js, code); + } + + // Create an error object so we can easily capture the stack where the + // process.exit call was made. + auto err = KJ_ASSERT_NONNULL( + js.error("process.exit(...) called without a current request context. Ignoring.") + .tryCast()); + err.set(js, "name"_kj, js.str()); + js.logWarning(kj::str(err.get(js, "stack"_kj))); +} + } // namespace workerd::api::node diff --git a/src/workerd/api/node/util.h b/src/workerd/api/node/util.h index 55153451137..5fd72f18924 100644 --- a/src/workerd/api/node/util.h +++ b/src/workerd/api/node/util.h @@ -216,6 +216,13 @@ class UtilModule final: public jsg::Object { jsg::JsValue getBuiltinModule(jsg::Lock& js, kj::String specifier); + // This is used in the implementation of process.exit(...). Contrary + // to what the name suggests, it does not actually exit the process. + // Instead, it will cause the IoContext, if any, and will stop javascript + // from further executing in that request. If there is no active IoContext, + // then it becomes a non-op. + void processExitImpl(jsg::Lock& js, int code); + JSG_RESOURCE_TYPE(UtilModule) { JSG_NESTED_TYPE(MIMEType); JSG_NESTED_TYPE(MIMEParams); @@ -243,6 +250,7 @@ class UtilModule final: public jsg::Object { JSG_METHOD(isBoxedPrimitive); JSG_METHOD(getBuiltinModule); + JSG_METHOD(processExitImpl); } };