diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index bfe379e7e722..8ace60a90577 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -32,7 +32,6 @@ unsafe extern "C" { safe fn JSC__VM__runGC(vm: &VM, sync: bool) -> usize; safe fn JSC__VM__heapSize(vm: &VM) -> usize; safe fn JSC__VM__collectAsync(vm: &VM); - safe fn JSC__VM__setExecutionForbidden(vm: &VM, forbidden: bool); safe fn JSC__VM__setExecutionTimeLimit(vm: &VM, timeout: f64); safe fn JSC__VM__clearExecutionTimeLimit(vm: &VM); safe fn JSC__VM__executionForbidden(vm: &VM) -> bool; @@ -130,10 +129,6 @@ impl VM { JSC__VM__collectAsync(self) } - pub fn set_execution_forbidden(&self, forbidden: bool) { - JSC__VM__setExecutionForbidden(self, forbidden) - } - pub fn set_execution_time_limit(&self, timeout: f64) { JSC__VM__setExecutionTimeLimit(self, timeout) } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 498fdcf9278f..0d2b51b2f3b6 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -59,6 +59,7 @@ #include "NodeValidator.h" #include "NodeModuleModule.h" #include "JSX509Certificate.h" +#include "vm/SigintWatcher.h" #include "AsyncContextFrame.h" #include "ErrorCode.h" @@ -1568,7 +1569,12 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e sigaddset(&action.sa_mask, signalNumber); action.sa_flags = SA_RESTART; - sigaction(signalNumber, &action, nullptr); + // The SIGINT watcher (`node:vm` breakOnSigint, the REPL) holds the + // disposition while the code doing this runs, so hand it the action + // rather than installing over its handler and disarming it. + if (signalNumber != SIGINT || !Bun::SigintWatcher::get().deferSigintDisposition(action)) { + sigaction(signalNumber, &action, nullptr); + } #else signal_handle.handle = Bun__UVSignalHandle__init( eventEmitter.scriptExecutionContext()->jsGlobalObject(), @@ -1585,9 +1591,19 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e if (signalToContextIdsMap->find(signalNumber) != signalToContextIdsMap->end() && eventEmitter.listenerCount(eventName) == 0) { #if !OS(WINDOWS) - if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) { - // Don't uninstall the old handler if it's not the one we installed. - signal(signalNumber, oldHandler); + struct sigaction action; + memset(&action, 0, sizeof(struct sigaction)); + action.sa_handler = SIG_DFL; + sigemptyset(&action.sa_mask); + + // Same as above: while the watcher is armed, the default we want + // back takes effect when it disarms, not now. Without this its + // handler is what `signal()` below would find and reinstate. + if (signalNumber != SIGINT || !Bun::SigintWatcher::get().deferSigintDisposition(action)) { + if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) { + // Don't uninstall the old handler if it's not the one we installed. + signal(signalNumber, oldHandler); + } } #else SignalHandleValue signal_handle = signalToContextIdsMap->get(signalNumber); diff --git a/src/jsc/bindings/NodeVMModule.cpp b/src/jsc/bindings/NodeVMModule.cpp index 23292142dc32..301b832c8424 100644 --- a/src/jsc/bindings/NodeVMModule.cpp +++ b/src/jsc/bindings/NodeVMModule.cpp @@ -245,16 +245,24 @@ JSValue NodeVMModule::evaluate(JSGlobalObject* globalObject, uint32_t timeout, b // so the exception-check validator is satisfied before the TOP scope. std::ignore = scope.exception(); if (vm.hasTerminationRequest() || vm.hasPendingTerminationException()) { + // An enclosing scope asked for the termination; only it can classify it. + // Returning is load-bearing: falling through would store the singleton + // TerminationException, whose re-throw later trips `VM::setException`. + if (!getSigintReceived() && timeout == 0) { + JSC::throwException(globalObject, scope, vm.ensureTerminationException()); + return {}; + } + // Despite the name this *clears* the queue, so scope it to the terminated + // context. `nodeVmGlobalObject` is null when there is no context (nothing + // to clear); passing `globalObject` would discard the main queue. vm.drainMicrotasksForGlobalObject(nodeVmGlobalObject); DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); vm.clearHasTerminationRequest(); if (getSigintReceived()) { setSigintReceived(false); throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s); - } else if (timeout != 0) { - throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, timeout, "ms"_s)); } else { - RELEASE_ASSERT_NOT_REACHED_WITH_MESSAGE("vm.SourceTextModule evaluation terminated due neither to SIGINT nor to timeout"); + throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, timeout, "ms"_s)); } } else { setSigintReceived(false); diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 90d1df51b524..d2ba3421a279 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -281,28 +281,36 @@ void NodeVMScript::destroy(JSCell* cell) static_cast(cell)->NodeVMScript::~NodeVMScript(); } -static bool checkForTermination(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, NodeVMScript* script, std::optional timeout) +static bool checkForTermination(JSC::VM& vm, JSC::JSGlobalObject* globalObject, NodeVMGlobalObject* contextGlobalObject, JSC::ThrowScope& scope, NodeVMScript* script, std::optional timeout) { - if (vm.hasTerminationRequest()) { - vm.drainMicrotasksForGlobalObject(globalObject); - // The termination may have fired inside an afterEvaluate microtask - // checkpoint, leaving the termination exception pending; clear it so - // the ERR_SCRIPT_EXECUTION_* error below replaces it. - if (vm.hasPendingTerminationException()) - DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); - vm.clearHasTerminationRequest(); - if (script->getSigintReceived()) { - script->setSigintReceived(false); - throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s); - } else if (timeout) { - throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, *timeout, "ms"_s)); - } else { - RELEASE_ASSERT_NOT_REACHED_WITH_MESSAGE("vm.Script terminated due neither to SIGINT nor to timeout"); - } + if (!vm.hasTerminationRequest()) + return false; + + // Neither this script's own SIGINT nor its own timeout, so an enclosing scope + // asked for the termination. Only that scope can classify it: re-raise and let + // its `checkForTermination` (or `Bun__REPL__evaluate`) report. + if (!script->getSigintReceived() && !timeout) { + JSC::throwException(globalObject, scope, vm.ensureTerminationException()); return true; } - return false; + // Despite the name this *clears* the queue, so scope it to the terminated + // context. `runInThisContext` has none of its own (null, nothing to clear); + // passing `globalObject` there would discard the caller's microtasks. + vm.drainMicrotasksForGlobalObject(contextGlobalObject); + // The termination may have fired inside an afterEvaluate microtask + // checkpoint, leaving the termination exception pending; clear it so + // the ERR_SCRIPT_EXECUTION_* error below replaces it. + if (vm.hasPendingTerminationException()) + DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); + vm.clearHasTerminationRequest(); + if (script->getSigintReceived()) { + script->setSigintReceived(false); + throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, "Script execution was interrupted by `SIGINT`"_s); + } else { + throwError(globalObject, scope, ErrorCode::ERR_SCRIPT_EXECUTION_TIMEOUT, makeString("Script execution timed out after "_s, *timeout, "ms"_s)); + } + return true; } void setupWatchdog(VM& vm, double timeout, double* oldTimeout, double* newTimeout) @@ -383,7 +391,7 @@ static JSC::EncodedJSValue runInContext(NodeVMGlobalObject* globalObject, NodeVM vm.watchdog()->setTimeLimit(WTF::Seconds::fromMilliseconds(*oldLimit)); } - if (checkForTermination(vm, globalObject, scope, script, newLimit)) { + if (checkForTermination(vm, globalObject, globalObject, scope, script, newLimit)) { return {}; } @@ -448,7 +456,9 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInThisContext, (JSGlobalObject * globalObject, vm.watchdog()->setTimeLimit(WTF::Seconds::fromMilliseconds(*oldLimit)); } - if (checkForTermination(vm, globalObject, scope, script, newLimit)) { + // `runInThisContext` evaluates in the caller's global, so there is no + // contextified global whose microtask queue the termination may clear. + if (checkForTermination(vm, globalObject, nullptr, scope, script, newLimit)) { return {}; } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index e694c218f38b..7727f6ecbc7a 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -12,9 +12,11 @@ #include "JavaScriptCore/ErrorType.h" #include "JavaScriptCore/TopExceptionScope.h" #include "JavaScriptCore/Exception.h" +#include "JavaScriptCore/VMTraps.h" #include "ErrorCode+List.h" #include "ErrorCode.h" #include "JavaScriptCore/ThrowScope.h" +#include "../vm/SigintWatcher.h" #include "JavaScriptCore/JSCast.h" #include "JavaScriptCore/JSType.h" @@ -4829,11 +4831,6 @@ bool JSC__VM__hasTerminationRequest(JSC::VM* vm) return vm->hasTerminationRequest(); } -void JSC__VM__setExecutionForbidden(JSC::VM* arg0, bool arg1) -{ - (*arg0).setExecutionForbidden(); -} - // These may be called concurrently from another thread. void JSC__VM__notifyNeedTermination(JSC::VM* arg0) { @@ -6266,6 +6263,81 @@ CPP_DECL [[ZIG_EXPORT(nothrow)]] unsigned int Bun__CallFrame__getLineNumber(JSC: return lineColumn.line; } +// Armed around one REPL evaluation. The receiver flag is the only durable record +// of the signal: JSC clears the trap bit and `hasTerminationRequest()` by the time +// the outermost VM entry scope has unwound. +namespace { +class ReplSigintScope final : public Bun::SigintReceiver { +public: + explicit ReplSigintScope(JSC::JSGlobalObject* globalObject) + : m_holder(Bun::SigintWatcher::hold(globalObject, this)) + { + } + +private: + Bun::SigintWatcher::GlobalObjectHolder m_holder; +}; +} + +// Drops whatever termination state a SIGINT left behind so the VM can run +// JavaScript again. +static void replClearTermination(JSC::VM& vm) +{ + vm.traps().clearTrap(JSC::VMTraps::NeedTermination); + if (vm.hasPendingTerminationException()) { + DECLARE_TOP_EXCEPTION_SCOPE(vm).clearException(); + } + vm.clearHasTerminationRequest(); +} + +// Arms SIGINT watching for `globalObject`: a SIGINT then raises a JSC termination +// trap that unwinds synchronous JS, the way node's `breakOnSigint` does. Pass the +// returned scope to `Bun__REPL__disarmSigint`. +extern "C" void* Bun__REPL__armSigint(JSC::JSGlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + // Allocate the termination exception up front: the trap handler runs at a + // point where allocating one is not allowed. + vm.ensureTerminationException(); + return new ReplSigintScope(globalObject); +} + +extern "C" void Bun__REPL__disarmSigint(JSC::JSGlobalObject* globalObject, void* scope) +{ + // Tears down the watcher thread, so no further signal can reach us. + delete static_cast(scope); + + auto& vm = JSC::getVM(globalObject); + // A signal that raced the disarm leaves a trap bit nobody will service; + // the next evaluation would terminate the instant it entered the VM. + if (vm.traps().hasTrapBit(JSC::VMTraps::NeedTermination)) [[unlikely]] { + replClearTermination(vm); + } +} + +extern "C" bool Bun__REPL__sigintRequested(void* scope) +{ + return scope && static_cast(scope)->getSigintReceived(); +} + +// Clears the SIGINT termination state and returns the error to report, or an +// empty JSValue when no interrupt is pending. +extern "C" JSC::EncodedJSValue Bun__REPL__takeSigintError(JSC::JSGlobalObject* globalObject, void* scope) +{ + if (!Bun__REPL__sigintRequested(scope)) { + return JSC::JSValue::encode({}); + } + + auto& vm = JSC::getVM(globalObject); + static_cast(scope)->setSigintReceived(false); + replClearTermination(vm); + + JSC::JSObject* error = Bun::createError(globalObject, Bun::ErrorCode::ERR_SCRIPT_EXECUTION_INTERRUPTED, + "Script execution was interrupted by `SIGINT`"_s); + globalObject->putDirect(vm, JSC::Identifier::fromString(vm, "_error"_s), error); + return JSC::JSValue::encode(error); +} + // REPL evaluation function - evaluates JavaScript code in the global scope // Returns the result value, or undefined if an exception was thrown // If an exception is thrown, the exception value is stored in *exception @@ -6296,6 +6368,14 @@ extern "C" JSC::EncodedJSValue Bun__REPL__evaluate( WTF::NakedPtr evalException; JSC::JSValue result = JSC::evaluate(globalObject, sourceCode, globalObject->globalThis(), evalException); + // SIGINT unwound the script, and `evalException` is the internal + // TerminatedExecutionError. The caller reports + // ERR_SCRIPT_EXECUTION_INTERRUPTED via `Bun__REPL__takeSigintError` instead. + if (evalException && vm.isTerminationException(evalException.get())) [[unlikely]] { + *exception = JSC::JSValue::encode(JSC::jsUndefined()); + return JSC::JSValue::encode(JSC::jsUndefined()); + } + if (evalException) { *exception = JSC::JSValue::encode(evalException->value()); // Set _error on the globalObject directly (not globalThis proxy) diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 750b3ac81d6b..099821bbdf36 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -318,7 +318,6 @@ CPP_DECL void JSC__VM__notifyNeedWatchdogCheck(JSC::VM* arg0); CPP_DECL void JSC__VM__releaseWeakRefs(JSC::VM* arg0); CPP_DECL size_t JSC__VM__runGC(JSC::VM* arg0, bool arg1); CPP_DECL void JSC__VM__setControlFlowProfiler(JSC::VM* arg0, bool arg1); -CPP_DECL void JSC__VM__setExecutionForbidden(JSC::VM* arg0, bool arg1); CPP_DECL void JSC__VM__setExecutionTimeLimit(JSC::VM* arg0, double arg1); CPP_DECL void JSC__VM__shrinkFootprint(JSC::VM* arg0); CPP_DECL void JSC__VM__throwError(JSC::VM* arg0, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue JSValue2); diff --git a/src/jsc/bindings/vm/SigintReceiver.h b/src/jsc/bindings/vm/SigintReceiver.h index fc66d43b3c58..303c72ae81a9 100644 --- a/src/jsc/bindings/vm/SigintReceiver.h +++ b/src/jsc/bindings/vm/SigintReceiver.h @@ -1,23 +1,27 @@ #pragma once +#include + namespace Bun { +// `m_sigintReceived` is written by the SigintWatcher thread and read by the VM +// thread, so it has to be atomic. class SigintReceiver { public: SigintReceiver() = default; void setSigintReceived(bool value = true) { - m_sigintReceived = value; + m_sigintReceived.store(value, std::memory_order_relaxed); } - bool getSigintReceived() + bool getSigintReceived() const { - return m_sigintReceived; + return m_sigintReceived.load(std::memory_order_relaxed); } protected: - bool m_sigintReceived = false; + std::atomic m_sigintReceived = false; }; } // namespace Bun diff --git a/src/jsc/bindings/vm/SigintWatcher.cpp b/src/jsc/bindings/vm/SigintWatcher.cpp index 0c2c99a1aa7e..53e0e516dcfc 100644 --- a/src/jsc/bindings/vm/SigintWatcher.cpp +++ b/src/jsc/bindings/vm/SigintWatcher.cpp @@ -20,6 +20,11 @@ static BOOL WindowsCtrlHandler(DWORD signal) return false; } +#else +static void sigintWatcherHandler(int) +{ + SigintWatcher::get().signalReceived(); +} #endif SigintWatcher::SigintWatcher() @@ -43,15 +48,15 @@ void SigintWatcher::install() struct sigaction action; memset(&action, 0, sizeof(struct sigaction)); - action.sa_handler = [](int signalNumber) { - get().signalReceived(); - }; + action.sa_handler = sigintWatcherHandler; sigemptyset(&action.sa_mask); sigaddset(&action.sa_mask, SIGINT); action.sa_flags = 0; - sigaction(SIGINT, &action, nullptr); + // Save what we displace. `ref()` only reaches install() on the 0 -> 1 + // transition, so this cannot capture our own handler. + sigaction(SIGINT, &action, &m_previousAction); #endif if (m_installed.exchange(true)) { @@ -83,26 +88,59 @@ void SigintWatcher::install() void SigintWatcher::uninstall() { - if (m_installed.exchange(false)) { - WTF::Thread* currentThread = WTF::Thread::currentMayBeNull(); - ASSERT(!currentThread || m_thread->uid() != currentThread->uid()); + if (!m_installed.load()) { + return; + } + + WTF::Thread* currentThread = WTF::Thread::currentMayBeNull(); + ASSERT(!currentThread || m_thread->uid() != currentThread->uid()); + // Hand the disposition back while still armed. Clearing m_installed first + // leaves a window where our handler is live but the watcher thread bails out + // of `signalAll`, so a signal landing in it reaches nobody at all. #if OS(WINDOWS) - SetConsoleCtrlHandler(WindowsCtrlHandler, false); + SetConsoleCtrlHandler(WindowsCtrlHandler, false); #else - struct sigaction action; - memset(&action, 0, sizeof(struct sigaction)); - action.sa_handler = Bun__onPosixSignal; - sigemptyset(&action.sa_mask); - sigaddset(&action.sa_mask, SIGINT); - action.sa_flags = SA_RESTART; - sigaction(SIGINT, &action, nullptr); + // Undo only our own handler: a native addon may have installed its own + // while we were armed, and clobbering it would strand that handler. + // `process.on("SIGINT")` instead routes through deferSigintDisposition. + struct sigaction current; + if (sigaction(SIGINT, nullptr, ¤t) == 0 + && !(current.sa_flags & SA_SIGINFO) + && current.sa_handler == sigintWatcherHandler) { + sigaction(SIGINT, &m_previousAction, nullptr); + } #endif - m_semaphore.signal(); - m_thread->waitForCompletion(); + // The restore above is idempotent, so a second caller that lost this race + // (only the destructor can, `deref` holds m_refCountMutex) stops here. + if (!m_installed.exchange(false)) { + return; + } + + m_semaphore.signal(); + m_thread->waitForCompletion(); +} + +#if !OS(WINDOWS) +bool SigintWatcher::deferSigintDisposition(const struct sigaction& action) +{ + WTF::Locker locker { m_refCountMutex }; + if (!m_installed.load()) { + return false; } + + struct sigaction current; + if (sigaction(SIGINT, nullptr, ¤t) != 0 + || (current.sa_flags & SA_SIGINFO) + || current.sa_handler != sigintWatcherHandler) { + return false; + } + + m_previousAction = action; + return true; } +#endif void SigintWatcher::signalReceived() { @@ -119,7 +157,10 @@ void SigintWatcher::registerGlobalObject(JSGlobalObject* globalObject) } WTF::Locker lock(m_globalObjectsMutex); - m_globalObjects.appendIfNotContains(globalObject); + // Append unconditionally so a nested holder unregisters only its own entry. + // `signalAll` tolerates duplicates: `notifyNeedTermination` just re-sets an + // already-set trap bit. + m_globalObjects.append(globalObject); } void SigintWatcher::unregisterGlobalObject(JSGlobalObject* globalObject) @@ -146,7 +187,10 @@ void SigintWatcher::registerReceiver(SigintReceiver* module) } WTF::Locker lock(m_receiversMutex); - m_receivers.appendIfNotContains(module); + // Append unconditionally, for the same reason as registerGlobalObject: + // `unregisterReceiver` removes one entry. `setSigintReceived` is an + // idempotent atomic store, so duplicates are harmless to `signalAll`. + m_receivers.append(module); } void SigintWatcher::unregisterReceiver(SigintReceiver* module) diff --git a/src/jsc/bindings/vm/SigintWatcher.h b/src/jsc/bindings/vm/SigintWatcher.h index 4b5ff92deb44..9a475e9ca984 100644 --- a/src/jsc/bindings/vm/SigintWatcher.h +++ b/src/jsc/bindings/vm/SigintWatcher.h @@ -20,6 +20,12 @@ class SigintWatcher { void install(); void uninstall(); void signalReceived(); +#if !OS(WINDOWS) + /** While armed we own the SIGINT disposition, so anything that wants to change + * it (`process.on("SIGINT")` and its removal) hands us the action to apply when + * we disarm. Returns false if we are not armed: install it yourself. */ + bool deferSigintDisposition(const struct sigaction& action); +#endif void registerGlobalObject(JSC::JSGlobalObject* globalObject); void unregisterGlobalObject(JSC::JSGlobalObject* globalObject); void registerReceiver(SigintReceiver* module); @@ -106,6 +112,11 @@ class SigintWatcher { WTF::Vector m_globalObjects; WTF::Vector m_receivers; uint32_t m_refCount = 0; +#if !OS(WINDOWS) + // What uninstall() puts back: the disposition install() displaced, unless + // deferSigintDisposition() has since replaced it. Guarded by m_refCountMutex. + struct sigaction m_previousAction {}; +#endif bool signalAll(); }; diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 96c1486cf41f..a32248ca4931 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -11,8 +11,7 @@ //! //! This replaces the TypeScript-based REPL for faster startup and better integration. -#[cfg(unix)] -use core::ffi::c_int; +use core::ffi::c_void; use core::fmt::Arguments; use std::io::Write as _; @@ -54,6 +53,34 @@ unsafe extern "C" { prefixPtr: *const u8, prefixLen: usize, ) -> JSValue; + + /// Arms the SIGINT watcher; the returned scope must be passed to + /// `Bun__REPL__disarmSigint` exactly once. + fn Bun__REPL__armSigint(globalObject: *const JSGlobalObject) -> *mut c_void; + fn Bun__REPL__disarmSigint(globalObject: *const JSGlobalObject, scope: *mut c_void); + fn Bun__REPL__sigintRequested(scope: *mut c_void) -> bool; + /// Clears the VM's SIGINT termination state and hands back the error to + /// report, or an empty `JSValue` when no interrupt is pending. + fn Bun__REPL__takeSigintError( + globalObject: *const JSGlobalObject, + scope: *mut c_void, + ) -> JSValue; +} + +/// Ctrl+C interruption armed for the duration of one evaluation. +struct SigintScope { + scope: *mut c_void, +} + +/// What one REPL evaluation produced. +enum EvalOutcome { + /// The value of the input, already unwrapped from the `{ value: ... }` + /// wrapper the REPL transform adds. + Value(JSValue), + /// A thrown exception, a rejected promise, or a Ctrl+C interrupt. + Error(JSValue), + /// A top-level await that never settled. Nothing to print. + Pending, } // ============================================================================ @@ -967,51 +994,68 @@ impl<'a> Repl<'a> { } } - /// Temporarily enable SIGINT delivery during blocking promise waits - fn enable_signals_during_wait(&mut self) { - if let Some(vm) = self.vm { - // Cleared in disable_signals_during_wait; Release pairs with the - // Acquire load in `sigint_handler`. - SIGINT_VM.store(vm.jsc_vm, core::sync::atomic::Ordering::Release); + /// Make Ctrl+C interrupt the evaluation that is about to run. + /// + /// Like node's REPL, hand the terminal back to the line discipline so Ctrl+C + /// arrives as SIGINT rather than a byte nobody reads, and arm the watcher so + /// the signal raises a JSC termination trap, which unwinds `while (true) {}`. + /// `None` when not attached to a terminal: SIGINT keeps killing the process. + fn begin_interruptible_eval(&mut self) -> Option { + let global = self.global?; + if !self.is_tty { + return None; } + // Arm before handing the terminal back, so the window where Ctrl+C still + // means "kill the process" stays closed. + // SAFETY: `global` is a live opaque `JSGlobalObject` handle; the scope is + // released exactly once in `end_interruptible_eval`. + let scope = unsafe { Bun__REPL__armSigint(global) }; + #[cfg(unix)] - { - // Switch to normal terminal mode (has ISIG) so Ctrl+C generates SIGINT - let _ = self.tty_state.set_mode(0, tty::Mode::Normal); + let _ = self.tty_state.set_mode(0, tty::Mode::Normal); + // On Windows, ENABLE_PROCESSED_INPUT is already set, so Ctrl+C already + // arrives as a console control event. - // Install SIGINT handler - // SAFETY: zeroed `sigaction` is a valid empty mask + null restorer; we set - // sa_sigaction/sa_flags below. `act` is valid for the duration of the call. - unsafe { - let mut act: bun_sys::posix::Sigaction = bun_core::ffi::zeroed(); - act.sa_sigaction = sigint_handler as *const () as usize; - act.sa_flags = 0; - bun_sys::posix::sigaction(libc::SIGINT, &raw const act, core::ptr::null_mut()); - } - } - // On Windows, ENABLE_PROCESSED_INPUT is already set so Ctrl+C works + Some(SigintScope { scope }) } - /// Restore raw terminal mode after promise wait - fn disable_signals_during_wait(&mut self) { - SIGINT_VM.store(core::ptr::null_mut(), core::sync::atomic::Ordering::Release); + fn end_interruptible_eval(&mut self, scope: Option) { + let Some(scope) = scope else { + return; + }; + // Before anything fallible: leaving the prompt in cooked mode is worse + // than any error we could hit below. #[cfg(unix)] - { - // Back to raw mode - let _ = self.tty_state.set_mode(0, tty::Mode::Raw); + let _ = self.tty_state.set_mode(0, tty::Mode::Raw); - // Restore default SIGINT handling - // SAFETY: zeroed `sigaction` is a valid empty mask + null restorer; SIG_DFL - // restores the default disposition. `act` is valid for the duration of the call. - unsafe { - let mut act: bun_sys::posix::Sigaction = bun_core::ffi::zeroed(); - act.sa_sigaction = libc::SIG_DFL; - act.sa_flags = 0; - bun_sys::posix::sigaction(libc::SIGINT, &raw const act, core::ptr::null_mut()); - } - } + // A scope is only handed out once `begin_interruptible_eval` has seen a + // global, and `self.global` is set once per session. + let global = self.global.expect("sigint scope armed without a global"); + // SAFETY: `scope.scope` came from `Bun__REPL__armSigint` and is consumed + // once; `global` is a live opaque `JSGlobalObject` handle. + unsafe { Bun__REPL__disarmSigint(global, scope.scope) }; + } + + /// Whether a Ctrl+C has asked the VM to stop running JavaScript. + fn sigint_requested(scope: Option<&SigintScope>) -> bool { + let Some(scope) = scope else { + return false; + }; + // SAFETY: `scope.scope` is live until `end_interruptible_eval`. + unsafe { Bun__REPL__sigintRequested(scope.scope) } + } + + /// Clear the VM's termination state and take the error to report, if the + /// evaluation was interrupted by Ctrl+C. + fn take_interrupt_error(&self, scope: Option<&SigintScope>) -> Option { + let global = self.global?; + let scope = scope?; + // SAFETY: `global` is a live opaque `JSGlobalObject` handle and + // `scope.scope` is live until `end_interruptible_eval`. + let error = unsafe { Bun__REPL__takeSigintError(global, scope.scope) }; + (!error.is_empty()).then_some(error) } fn write(&self, data: &[u8]) { @@ -1261,134 +1305,145 @@ impl<'a> Repl<'a> { // JavaScript Evaluation // ======================================================================== - fn evaluate_and_print(&mut self, code: &[u8]) { + /// Evaluate already-transformed REPL input, resolving the async IIFE the + /// transform emits for top-level `await`. Ctrl+C interrupts both the + /// synchronous evaluation and the wait for the promise. + fn evaluate_transformed(&mut self, code: &[u8]) -> EvalOutcome { let Some(global) = self.global else { - return; + return EvalOutcome::Pending; }; let Some(vm) = self.vm else { - return; + return EvalOutcome::Pending; }; - // Transform the code using REPL mode (hoists declarations, wraps result in { value: expr }) - let Some(transformed_code) = self.transform_for_repl(code) else { - // Transform failed, try evaluating raw code (for syntax errors, etc.) - self.evaluate_raw(code); - return; - }; + let sigint = self.begin_interruptible_eval(); - // Evaluate the transformed code let mut exception: JSValue = JSValue::UNDEFINED; // SAFETY: `global` is a live opaque `JSGlobalObject` handle; slice ptr/len pairs // are valid for the duration of the call; `exception` is a stack local. let result = unsafe { Bun__REPL__evaluate( global, - transformed_code.as_ptr(), - transformed_code.len(), + code.as_ptr(), + code.len(), b"[repl]".as_ptr(), b"[repl]".len(), &raw mut exception, ) }; - // Check for exception - if !exception.is_undefined() && !exception.is_null() { - self.set_last_error(exception); - self.print_js_error(exception); - return; - } - - // Handle async IIFE results - wait for promise to resolve - let mut resolved_result = result; - if let Some(promise) = result.as_promise() { + let mut outcome = if !exception.is_undefined() && !exception.is_null() { + EvalOutcome::Error(exception) + } else if let Some(promise) = result.as_promise() { + // The wait ticks the VM, which can collect; keep the promise rooted. + let _rooted = result.protected(); // Mark as handled BEFORE waiting to prevent unhandled rejection output jsc::JSPromise::opaque_mut(promise).set_handled(); - - // Temporarily re-enable signal delivery so Ctrl+C can interrupt - // the blocking waitForPromise call - self.enable_signals_during_wait(); - // Note: reshaped for borrowck — call disable_signals_during_wait() explicitly on each return path below - - // Wait for the promise to settle - vm.as_mut() - .wait_for_promise(jsc::AnyPromise::Normal(promise)); - - // If execution was forbidden by SIGINT, clear it and report - if vm.jsc_vm().execution_forbidden() { - vm.jsc_vm().set_execution_forbidden(false); - global.clear_termination_exception(); - self.print(format_args!("\n")); - self.disable_signals_during_wait(); - return; - } - + self.wait_for_promise_interruptible(promise, sigint.as_ref()); // SAFETY: `vm.jsc_vm` is the live JSC VM handle for this thread. let jsc_vm_ref = vm.jsc_vm(); - // Check promise status after waiting match jsc::JSPromise::opaque_mut(promise).status() { PromiseStatus::Fulfilled => { - resolved_result = jsc::JSPromise::opaque_mut(promise).result(jsc_vm_ref); + EvalOutcome::Value(jsc::JSPromise::opaque_mut(promise).result(jsc_vm_ref)) } PromiseStatus::Rejected => { - let rejection = jsc::JSPromise::opaque_mut(promise).result(jsc_vm_ref); - self.set_last_error(rejection); - // Set _error on the global object - let global_this = global.to_js_value(); - global_this.put(global, b"_error", rejection); - self.print_js_error(rejection); - self.disable_signals_during_wait(); - return; - } - PromiseStatus::Pending => { - // Interrupted by signal or timed out - self.print(format_args!("\n")); - self.disable_signals_during_wait(); - return; + EvalOutcome::Error(jsc::JSPromise::opaque_mut(promise).result(jsc_vm_ref)) } + PromiseStatus::Pending => EvalOutcome::Pending, } - self.disable_signals_during_wait(); - } + } else { + EvalOutcome::Value(result) + }; - // Extract the value from the result wrapper { value: expr } - // The REPL transform wraps the last expression in { value: expr } - let mut actual_result = resolved_result; - if resolved_result.is_object() { - // Wrapper is REPL-built { __proto__: null, value: ... } so getOwn shouldn't throw, - // but if it does, propagate as a REPL error. - let maybe_value = - match resolved_result.get_own(global, &bun_core::String::static_("value")) { - Ok(v) => v, - Err(err) => { - let exc = global.take_exception(err); - self.set_last_error(exc); - self.print_js_error(exc); - vm.as_mut().tick(); - return; - } + // A Ctrl+C supersedes whatever the evaluation left behind, and the VM + // has to come out of its terminated state before it runs JS again. + if let Some(error) = self.take_interrupt_error(sigint.as_ref()) { + outcome = EvalOutcome::Error(error); + } + self.end_interruptible_eval(sigint); + + // Unwrap the `{ value: expr }` wrapper the REPL transform adds. It is a + // REPL-built `{ __proto__: null, value: ... }` so getOwn shouldn't throw, + // but if it does, propagate as a REPL error. + if let EvalOutcome::Value(value) = outcome { + if value.is_object() { + outcome = match value.get_own(global, &bun_core::String::static_("value")) { + Ok(Some(inner)) => EvalOutcome::Value(inner), + Ok(None) => EvalOutcome::Value(value), + Err(err) => EvalOutcome::Error(global.take_exception(err)), }; - if let Some(value) = maybe_value { - actual_result = value; } } + outcome + } - // Store and print result - self.set_last_result(actual_result); - - // Set _ to the last result (only if not undefined) - // Use the global object as JSValue and put the property on it - if !actual_result.is_undefined() { - let global_this = global.to_js_value(); - global_this.put(global, b"_", actual_result); + /// Wait for `promise` to settle while running the event loop, returning + /// early when Ctrl+C asks the VM to stop. + fn wait_for_promise_interruptible( + &mut self, + promise: *mut jsc::JSPromise, + sigint: Option<&SigintScope>, + ) { + let Some(vm) = self.vm else { + return; + }; + let pending = + |promise| jsc::JSPromise::opaque_mut(promise).status() == PromiseStatus::Pending; + while pending(promise) && !Self::sigint_requested(sigint) { + vm.as_mut().tick(); + if !pending(promise) || Self::sigint_requested(sigint) { + return; + } + // With no live handles `auto_tick` never blocks, so nothing can settle + // the promise and looping would just burn a core. + if !vm.is_event_loop_alive() { + return; + } + // Parks in the poller. Every path in `us_loop_run_bun_tick` re-enters + // it on EINTR, so a Ctrl+C landing after the park is only seen once + // something else wakes the loop. + vm.as_mut().auto_tick(); } + } - if actual_result.is_undefined() { - if self.use_colors { - self.print(format_args!("{}undefined{}\n", Color::DIM, Color::RESET)); - } else { - self.print(format_args!("undefined\n")); + fn evaluate_and_print(&mut self, code: &[u8]) { + let Some(global) = self.global else { + return; + }; + let Some(vm) = self.vm else { + return; + }; + + // Transform the code using REPL mode (hoists declarations, wraps result in { value: expr }) + let Some(transformed_code) = self.transform_for_repl(code) else { + // Transform failed, try evaluating raw code (for syntax errors, etc.) + self.evaluate_raw(code); + return; + }; + + match self.evaluate_transformed(&transformed_code) { + EvalOutcome::Error(error) => { + self.set_last_error(error); + let global_this = global.to_js_value(); + global_this.put(global, b"_error", error); + self.print_js_error(error); + } + EvalOutcome::Pending => self.print(format_args!("\n")), + EvalOutcome::Value(value) => { + self.set_last_result(value); + if value.is_undefined() { + if self.use_colors { + self.print(format_args!("{}undefined{}\n", Color::DIM, Color::RESET)); + } else { + self.print(format_args!("undefined\n")); + } + } else { + // Set `_` to the last result (only if not undefined) + let global_this = global.to_js_value(); + global_this.put(global, b"_", value); + self.print_formatted_value(value); + } } - } else { - self.print_formatted_value(actual_result); } // Tick the event loop to handle any pending work @@ -1584,90 +1639,26 @@ impl<'a> Repl<'a> { return; }; - let mut exception: JSValue = JSValue::UNDEFINED; - // SAFETY: `global` is a live opaque `JSGlobalObject` handle; slice ptr/len pairs - // are valid for the duration of the call; `exception` is a stack local. - let result = unsafe { - Bun__REPL__evaluate( - global, - transformed_code.as_ptr(), - transformed_code.len(), - b"[repl]".as_ptr(), - b"[repl]".len(), - &raw mut exception, - ) - }; - - if !exception.is_undefined() && !exception.is_null() { - self.set_last_error(exception); - self.print_js_error(exception); - return; - } - - let mut resolved_result = result; - if let Some(promise) = result.as_promise() { - // SAFETY: `promise` is a live JSC heap cell; `vm.jsc_vm` is the - // owning JSC VM handle for this thread. - jsc::JSPromise::opaque_mut(promise).set_handled(); - self.enable_signals_during_wait(); - // Note: reshaped for borrowck — disable_signals_during_wait called on each path - vm.as_mut() - .wait_for_promise(jsc::AnyPromise::Normal(promise)); - if vm.jsc_vm().execution_forbidden() { - vm.jsc_vm().set_execution_forbidden(false); - global.clear_termination_exception(); - self.print(format_args!("\n")); - self.disable_signals_during_wait(); - return; + match self.evaluate_transformed(&transformed_code) { + EvalOutcome::Error(error) => { + self.set_last_error(error); + global.to_js_value().put(global, b"_error", error); + self.print_js_error(error); } - let jsc_vm_ref = vm.jsc_vm(); - match jsc::JSPromise::opaque_mut(promise).status() { - PromiseStatus::Fulfilled => { - resolved_result = jsc::JSPromise::opaque_mut(promise).result(jsc_vm_ref) - } - PromiseStatus::Rejected => { - let rejection = jsc::JSPromise::opaque_mut(promise).result(jsc_vm_ref); - self.set_last_error(rejection); - self.print_js_error(rejection); - self.disable_signals_during_wait(); - return; + EvalOutcome::Pending => {} + EvalOutcome::Value(value) => { + self.set_last_result(value); + if !value.is_undefined() { + let global_this = global.to_js_value(); + global_this.put(global, b"_", value); } - PromiseStatus::Pending => { - self.disable_signals_during_wait(); - return; + if let Err(err) = self.copy_value_to_clipboard(value) { + let exc = global.take_exception(err); + self.set_last_error(exc); + global.to_js_value().put(global, b"_error", exc); + self.print_js_error(exc); } } - self.disable_signals_during_wait(); - } - - let mut actual_result = resolved_result; - if resolved_result.is_object() { - let maybe_value = - match resolved_result.get_own(global, &bun_core::String::static_("value")) { - Ok(v) => v, - Err(err) => { - let exc = global.take_exception(err); - self.set_last_error(exc); - self.print_js_error(exc); - vm.as_mut().tick(); - return; - } - }; - if let Some(value) = maybe_value { - actual_result = value; - } - } - - self.set_last_result(actual_result); - if !actual_result.is_undefined() { - let global_this = global.to_js_value(); - global_this.put(global, b"_", actual_result); - } - - if let Err(err) = self.copy_value_to_clipboard(actual_result) { - let exc = global.take_exception(err); - self.set_last_error(exc); - self.print_js_error(exc); } vm.as_mut().tick(); } @@ -2435,23 +2426,6 @@ impl<'a> Drop for Repl<'a> { } } -/// Global pointer for signal handler to access the VM. -// PORTING.md §Global mutable state: read from a signal handler → AtomicPtr. -// Atomics are async-signal-safe; the previous raw-global `Option<*mut>` was -// not. `null` encodes `None`. -static SIGINT_VM: core::sync::atomic::AtomicPtr = - core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); - -#[cfg(unix)] -extern "C" fn sigint_handler(_: c_int) { - let vm = SIGINT_VM.load(core::sync::atomic::Ordering::Acquire); - if !vm.is_null() { - // `vm` was a valid `*mut jsc::VM` when stored (JS thread is - // blocked in wait while the handler runs, so it stays valid). - jsc::VM::opaque_ref(vm).set_execution_forbidden(true); - } -} - fn is_incomplete_code(code: &[u8]) -> bool { let mut brace_count: i32 = 0; let mut bracket_count: i32 = 0; diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 0d8e2ce79af9..0fc0143a589d 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -51,27 +51,30 @@ async function withTerminalRepl( let cursor = 0; let resolveWaiter: (() => void) | null = null; - await using terminal = new Bun.Terminal({ - cols: 120, - rows: 40, - data(_term, data) { - const str = Buffer.from(data).toString(); - received.push(str); - if (resolveWaiter) { - resolveWaiter(); - resolveWaiter = null; - } - }, - }); - + // The inline `terminal` option is what makes the child a session leader with + // the pty as its controlling terminal, so Ctrl+C reaches it as SIGINT. + // Handing `Bun.spawn` an already-created `Bun.Terminal` skips that setup. await using proc = Bun.spawn({ cmd: [bunExe(), "repl"], - terminal, env: { ...bunEnv, TERM: "xterm-256color", }, + terminal: { + cols: 120, + rows: 40, + data(_term, data) { + const str = Buffer.from(data).toString(); + received.push(str); + if (resolveWaiter) { + resolveWaiter(); + resolveWaiter = null; + } + }, + }, }); + const terminal = proc.terminal!; + using closeTerminal = { [Symbol.dispose]: () => terminal.close() }; const send = (text: string) => terminal.write(text); @@ -91,11 +94,14 @@ async function withTerminalRepl( `Timed out waiting for pattern: ${pattern}\nReceived so far:\n${stripAnsi(received.join("").slice(cursor))}`, ); } - // Wait for the next chunk of terminal data (or time out). - - await new Promise(resolve => { - resolveWaiter = resolve; - }); + // Wait for the next chunk of terminal data, or for the deadline: without + // the race this sleeps forever when the child goes quiet. + await Promise.race([ + new Promise(resolve => { + resolveWaiter = resolve; + }), + Bun.sleep(remaining), + ]); resolveWaiter = null; } }; @@ -993,6 +999,146 @@ describe.todoIf(isWindows)("Bun REPL (Terminal)", () => { }); }); + // Arming the SIGINT watcher around every evaluation must put the previous + // disposition back, or an external SIGINT is swallowed from then on and the + // startup handler that restores the terminal never runs. + test("an external SIGINT at the prompt still terminates the REPL", async () => { + await withTerminalRepl(async ({ send, waitFor, proc }) => { + send("1 + 1\n"); + await waitFor(/\n\s*2\b/); + + proc.kill("SIGINT"); + const exited = await Promise.race([ + proc.exited.then(() => "exited"), + Bun.sleep(4000).then(() => "still running"), + ]); + expect(exited).toBe("exited"); + }); + }); + + // The disarm must not clobber a handler installed while the watcher was armed: + // BunProcess registers its SIGINT handler exactly once, so discarding it would + // strand the listener for the rest of the session. + test("a SIGINT listener registered in the REPL survives the evaluation", async () => { + await withTerminalRepl(async ({ send, waitFor, proc }) => { + // Split the literal so the echoed input line can't satisfy the wait. + send("process.on('SIGINT', () => console.log('CAUGHT_' + 'SIGINT')); 1 + 1\n"); + await waitFor(/\n\s*2\b/); + + proc.kill("SIGINT"); + // The loop only runs during an evaluation, so the queued signal is handed + // to the listener on the next one. The signal is already pending when the + // keystrokes are written, and a handler runs before the read they satisfy + // returns to userspace, so the marker can't be missed. + send("3 + 4\n"); + // Getting the marker at all proves the process survived and the listener, + // rather than the watcher's handler, is what the disposition points at. + await waitFor("CAUGHT_SIGINT"); + }); + }); + + // And the mirror: dropping the last listener has to put the default action back. + // BunProcess reinstates the watcher's own handler rather than uninstalling it, + // so the disarm used to see its handler intact and restore the stale snapshot. + test("removing the last SIGINT listener restores the default disposition", async () => { + await withTerminalRepl(async ({ send, waitFor, proc }) => { + send("globalThis.h = () => {}; process.on('SIGINT', globalThis.h); 1 + 1\n"); + await waitFor(/\n\s*2\b/); + + send("process.removeListener('SIGINT', globalThis.h); 3 + 4\n"); + await waitFor(/\n\s*7\b/); + + proc.kill("SIGINT"); + const outcome = await Promise.race([ + proc.exited.then(() => "exited"), + Bun.sleep(4000).then(() => "still running"), + ]); + expect(outcome).toBe("exited"); + }); + }); + + // Adding a listener used to install over the watcher's handler, downgrading + // Ctrl+C to a JS event that the loop it is meant to break out of never drains. + test("Ctrl+C interrupts a loop that installed a SIGINT listener first", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + send(`process.on("SIGINT", () => {}); process.stdout.write("LOOP" + "ING\\n"); while (true) {}\n`); + await waitFor("LOOPING"); + send("\x03"); // Ctrl+C + const output = await waitFor(/interrupted/); + expect(stripAnsi(output)).toContain("Script execution was interrupted by `SIGINT`"); + + send("111 + 222\n"); + await waitFor(/\n\s*333\b/); + }); + }); + + // The REPL used to stay in raw mode while evaluating, so Ctrl+C was delivered + // as a byte nobody read and a synchronous loop could only be escaped by + // killing the process (which left the terminal in raw mode). + test("Ctrl+C interrupts a synchronous infinite loop", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + // Print from inside the evaluation so the interrupt is sent only once the + // loop is actually running. Split the literal so the echoed input line + // can't satisfy the wait. + send(`process.stdout.write("RUN" + "NING\\n"); while (true) {}\n`); + await waitFor("RUNNING"); + send("\x03"); // Ctrl+C + const output = await waitFor(/interrupted/); + expect(stripAnsi(output)).toContain("Script execution was interrupted by `SIGINT`"); + + // And the session keeps working. + send("111 + 222\n"); + await waitFor(/\n\s*333\b/); + }); + }); + + test("Ctrl+C during a never-settling await leaves the REPL usable", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + // The executor runs synchronously, so printing from it marks the point where + // the REPL starts waiting. The short interval keeps the loop alive, so the + // wait parks in the poller but wakes often enough to notice the interrupt. + send(`await new Promise(() => { process.stdout.write("WAIT" + "ING\\n"); setInterval(() => {}, 10); })\n`); + await waitFor("WAITING"); + send("\x03"); // Ctrl+C + await waitFor(/interrupted/); + + // Interrupting used to leave the VM permanently execution-forbidden, + // which silently dropped every microtask from then on. + send("await Promise.resolve(1234 * 1000)\n"); + await waitFor(/\n\s*1234000\b/); + }); + }); + + // The inner script's termination reached `checkForTermination` with neither a + // SIGINT flag of its own nor a timeout, which used to abort the process. + test("Ctrl+C during a nested vm.runInThisContext does not abort", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + // No `\n` escape inside the nested string: the REPL mishandles one there + // (pre-existing, reproduces on release bun), and the marker doesn't need it. + send(`require('vm').runInThisContext('process.stdout.write("IN"+"NER"); while (true) {}')\n`); + await waitFor("INNER"); + send("\x03"); // Ctrl+C + await waitFor(/interrupted/); + + // Reaching a result at all proves the process survived the interrupt. + send("111 + 222\n"); + await waitFor(/\n\s*333\b/); + }); + }); + + // The inner `breakOnSigint` holder used to unregister the REPL's own global on + // the way out, leaving the rest of the evaluation uninterruptible. + test("Ctrl+C still interrupts after a nested breakOnSigint script", async () => { + await withTerminalRepl(async ({ send, waitFor }) => { + send( + `require('vm').runInThisContext('1', { breakOnSigint: true }); process.stdout.write("AFT"+"ER\\n"); while (true) {}\n`, + ); + await waitFor("AFTER"); + send("\x03"); // Ctrl+C + await waitFor(/interrupted/); + }); + }); + test("require works in terminal", async () => { await withTerminalRepl(async ({ send, waitFor }) => { send("typeof require\n"); diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts index 81e49e2e84b3..2d6560861f2f 100644 --- a/test/js/node/vm/vm.test.ts +++ b/test/js/node/vm/vm.test.ts @@ -86,6 +86,30 @@ describe("vm", () => { }); expect(result).toBe(2); }); + + // An outer watchdog firing while a nested no-options script is on the stack + // used to abort the process, and then to report it as a SIGINT. Only the + // scope that armed the termination can classify it. Spawned, so a regression + // back to the abort is an attributable failure rather than a dead runner. + test("an outer timeout reports ERR_SCRIPT_EXECUTION_TIMEOUT through a nested script", async () => { + const fixture = ` + const vm = require("node:vm"); + globalThis.__nestedSpin = () => vm.runInThisContext("while (true) {}"); + try { + vm.runInThisContext("__nestedSpin()", { timeout: 100 }); + console.log("NO_THROW"); + } catch (e) { + console.log(e.code + " " + e.message); + } + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + capture(stderr); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: "ERR_SCRIPT_EXECUTION_TIMEOUT Script execution timed out after 100ms", + exitCode: 0, + }); + }); }); describe("compileFunction()", () => { @@ -941,6 +965,110 @@ test("Loader is not defined in vm context", () => { expect(runInContext("typeof Loader.registry;", customContext)).toBe("undefined"); }); +// `drainMicrotasksForGlobalObject` *clears* rather than drains, so a terminated +// module must only clear its own context's global. Passing the caller's global +// discarded the main thread's pending microtasks and wedged the process. +test("a terminated context-less module does not discard the main microtask queue", async () => { + const fixture = ` + const vm = require("node:vm"); + const m = new vm.SourceTextModule("while (true) {}"); + await m.link(() => {}); + + const { promise, resolve } = Promise.withResolvers(); + const chain = promise.then(() => "survived"); + resolve(); // the continuation is now parked in the main microtask queue + + m.evaluate({ timeout: 100 }).then(() => console.log("NO_THROW"), e => console.log("threw=" + e.code)); + // Clearing the queue drops the continuation, so this never prints and the + // process exits with nothing left to do. + chain.then(v => console.log("chain=" + v)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + capture(stderr); + expect({ stdout: stdout.trim().split("\n"), exitCode }).toEqual({ + stdout: ["threw=ERR_SCRIPT_EXECUTION_TIMEOUT", "chain=survived"], + exitCode: 0, + }); +}); + +// Same clear, reached from the script side. `runInThisContext` evaluates in the +// caller's global, so there is no context queue to clear and the caller's one is +// not ours to discard. Node keeps the continuation here too. +test("a timed-out runInThisContext does not discard the main microtask queue", async () => { + const fixture = ` + const vm = require("node:vm"); + + const { promise, resolve } = Promise.withResolvers(); + const chain = promise.then(() => "survived"); + resolve(); // the continuation is now parked in the main microtask queue + + try { + vm.runInThisContext("while (true) {}", { timeout: 100 }); + console.log("NO_THROW"); + } catch (e) { + console.log("threw=" + e.code); + } + // Clearing the queue drops the continuation, so this never prints and the + // process exits with nothing left to do. + chain.then(v => console.log("chain=" + v)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + capture(stderr); + expect({ stdout: stdout.trim().split("\n"), exitCode }).toEqual({ + stdout: ["threw=ERR_SCRIPT_EXECUTION_TIMEOUT", "chain=survived"], + exitCode: 0, + }); +}); + +// Re-raising an enclosing scope's termination must not hand the VM's singleton +// TerminationException to the module: storing it and re-throwing it once the +// request is cleared trips `VM::setException`'s +// `!isTerminationException(e) || hasTerminationRequest()` assertion. +test("a module terminated by an enclosing scope ends up errored without aborting", async () => { + const fixture = ` + const vm = require("node:vm"); + const m = new vm.SourceTextModule("while (true) {}"); + await m.link(() => {}); + globalThis.__run = () => { + const p = m.evaluate(); + p.catch(() => {}); + return p; + }; + try { + vm.runInThisContext("__run()", { timeout: 100 }); + console.log("outer=NO_THROW"); + } catch (e) { + console.log("outer=" + e.code); + } + console.log("status=" + m.status); + try { + m.error; + console.log("error=readable"); + } catch (e) { + console.log("error=threw:" + e.code); + } + // Re-evaluating an errored module re-throws the error it stored. That is + // where a stored TerminationException trips the assertion. + try { + const again = m.evaluate(); + again.catch(() => {}); + console.log("reeval=ok"); + } catch (e) { + console.log("reeval=threw:" + (e.code ?? e.name)); + } + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Before the assertion: a native abort makes stderr the useful diagnostic. + capture(stderr); + expect({ stdout: stdout.trim().split("\n"), exitCode }).toEqual({ + stdout: ["outer=ERR_SCRIPT_EXECUTION_TIMEOUT", "status=errored", "error=readable", "reeval=ok"], + exitCode: 0, + }); +}); + test("node:vm native Module prototype methods reject non-module receivers", async () => { // The native NodeVMModule prototype (reachable via the kNative own-symbol on a // vm.SourceTextModule instance) must validate its receiver. Calling its methods