diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index ba4d9fef7079..1448ac37a0e9 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -547,11 +547,6 @@ export function windowsEnv( return delete internalEnv[k]; }, defineProperty(_, p, attributes) { - // String(symbol) does not throw (it returns the descriptive string), so - // reject symbol keys explicitly like the set trap does. - if (typeof p === "symbol") { - throw new TypeError("Cannot convert a Symbol value to a string"); - } // Same validation as JSEnvironmentVariableMap::defineOwnProperty on // POSIX: only plain, fully-permissive data descriptors are accepted. if ("get" in attributes || "set" in attributes) { @@ -571,6 +566,12 @@ export function windowsEnv( "'process.env' only accepts a configurable, writable, and enumerable data descriptor", ); } + // Node coerces the key only after the descriptor validates; String(symbol) + // does not throw (it returns the descriptive string), so reject symbol + // keys explicitly like the set trap does. + if (typeof p === "symbol") { + throw new TypeError("Cannot convert a Symbol value to a string"); + } if (typeof attributes.value === "symbol") { throw new TypeError("Cannot convert a Symbol value to a string"); } @@ -596,6 +597,9 @@ export function windowsEnv( // .slice() because paranoia that there is a way to call this without the engine cloning it for us return envMapList.slice(); }, + preventExtensions() { + return false; + }, }); } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 072d7d96425d..c865bef47be3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2080,7 +2080,8 @@ impl VirtualMachine { } /// The subset of a Worker's `execArgv` that bun acts on (node's per-Environment options). -#[derive(Default)] +/// `Clone` because the inheriting-worker defaults are computed once per process and handed out. +#[derive(Default, Clone)] pub struct WorkerExecArgv { pub allow_addons: Option, pub use_system_ca: Option, @@ -2090,6 +2091,9 @@ pub struct WorkerExecArgv { pub cpu_prof_interval: Option, pub cpu_prof_name: Option>, pub cpu_prof_dir: Option>, + pub expose_gc: bool, + /// `--require`/`-r`/`--preload`/`--import` specifiers, in order. + pub preloads: Vec>, } pub struct RuntimeHooks { @@ -2213,9 +2217,14 @@ pub struct RuntimeHooks { transpiler: *mut Transpiler<'static>, graph: &'static dyn bun_resolver::StandaloneModuleGraph, ), - /// Parse `execArgv` against the `RunCommand` param table (lives in `bun_runtime::cli`, forward-dep). - /// Caller writes `allow_addons` back into `transform_options` and applies `cpu_prof` to the worker VM. - pub parse_worker_exec_argv: unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, + /// Parse a worker's `execArgv` (`bun_runtime::cli::worker_exec_argv`, + /// forward-dep); `None` derives an inheriting worker's defaults from the + /// process argv. cpu-prof is excluded (the parent VM carries it via + /// `parent_cpu_profiler_config`); preloads are excluded too, so an + /// inheriting worker does not re-run CLI `-r` (only an explicit execArgv + /// does; node re-runs in both — widening is a behavior decision). + pub parse_worker_exec_argv: + unsafe fn(exec_argv: Option<&[bun_core::WTFStringImpl]>) -> WorkerExecArgv, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. pub stop_cron_for_vm_teardown: fn(vm: &mut VirtualMachine), @@ -2865,6 +2874,14 @@ impl VirtualMachine { self.pending_internal_promise_is_protected = true; return Ok(p); } + // `load_preloads` returns null when the stop gate closed while + // a preload was evaluating (worker terminate()); there is + // nothing left to run, so do not load the entry. Same outcome + // `load_entry_point_for_web_worker` reports for a stop that + // lands after evaluation. + if !self.script_allowed() { + return Err(crate::CrateError::WorkerTerminated); + } } // Check if Module.runMain was patched. diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index d1ce4e83dc24..a2b20a5b4495 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -367,6 +367,7 @@ const errors: ErrorCodeMapping = [ // llhttp reports a missing CRLF after a chunk's data as HPE_STRICT, // distinct from a malformed chunk-size line (HPE_INVALID_CHUNK_SIZE). ["HPE_STRICT", Error], + ["ERR_WORKER_INVALID_EXEC_ARGV", Error], ["ERR_NOT_BUILDING_SNAPSHOT", Error], ["ERR_CANNOT_WATCH_SIGINT", Error], ["ERR_INSPECTOR_NOT_AVAILABLE", Error], diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index a6c8137751bb..1094f3180b90 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -137,7 +137,7 @@ bool JSEnvironmentVariableMap::put(JSCell* cell, JSGlobalObject* globalObject, P auto* uid = propertyName.uid(); if (uid && uid->isSymbol()) { - throwTypeError(globalObject, scope, "Cannot convert a symbol to a string"_s); + throwTypeError(globalObject, scope, "Cannot convert a Symbol value to a string"_s); return false; } @@ -633,6 +633,10 @@ class JSSharedEnvMap final : public JSC::JSNonFinalObject { static bool deletePropertyByIndex(JSCell*, JSGlobalObject*, unsigned); static void getOwnPropertyNames(JSObject*, JSGlobalObject*, JSC::PropertyNameArrayBuilder&, JSC::DontEnumPropertiesMode); static bool defineOwnProperty(JSObject*, JSGlobalObject*, JSC::PropertyName, const JSC::PropertyDescriptor&, bool shouldThrow); + static bool preventExtensions(JSC::JSObject*, JSC::JSGlobalObject*) + { + return false; + } private: JSSharedEnvMap(JSC::VM& vm, JSC::Structure* structure) @@ -753,7 +757,11 @@ bool JSSharedEnvMap::put(JSCell* cell, JSGlobalObject* globalObject, PropertyNam auto scope = DECLARE_THROW_SCOPE(vm); auto* uid = propertyName.uid(); - if (propertyName.isSymbol() || !uid) { + if (propertyName.isSymbol()) { + throwTypeError(globalObject, scope, "Cannot convert a Symbol value to a string"_s); + return false; + } + if (!uid) { RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); } @@ -835,23 +843,22 @@ bool JSSharedEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalO return false; } - if (propertyName.isSymbol() || !uid || !descriptor.isDataDescriptor() || !descriptor.value()) { - // getOwnPropertySlot reads the store first; move the entry onto Base so a partial - // descriptor keeps enumerability. Node's EnvDefiner also rejects partials (the regular - // map does); tightening SHARE_ENV is a separate behavior change with its own tests. - if (!propertyName.isSymbol() && uid) { - if (auto* store = sharedEnvStoreFor(object)) { - String existing = store->get(String(uid)); - if (!existing.isNull()) { - syncWindowsEnv(store, String(uid), nullptr); - store->remove(String(uid)); - object->putDirect(vm, propertyName, jsString(vm, existing), 0); - } - } - } - RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + if (!descriptor.value() + || !descriptor.writablePresent() || !descriptor.writable() + || !descriptor.enumerablePresent() || !descriptor.enumerable() + || !descriptor.configurablePresent() || !descriptor.configurable()) { + throwError(globalObject, scope, ErrorCode::ERR_INVALID_OBJECT_DEFINE_PROPERTY, "'process.env' only accepts a configurable, writable, and enumerable data descriptor"_s); + return false; + } + + if (propertyName.isSymbol()) { + throwTypeError(globalObject, scope, "Cannot convert a Symbol value to a string"_s); + return false; } + if (!uid) [[unlikely]] + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + maybeEmitEnvNonstringDeprecation(globalObject, scope, descriptor.value()); RETURN_IF_EXCEPTION(scope, false); String stringValue = descriptor.value().toWTFString(globalObject); @@ -1132,7 +1139,23 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) } #if OS(WINDOWS) - auto editWindowsEnvVar = JSC::JSFunction::create(vm, globalObject, 0, String("editWindowsEnvVar"_s), jsEditWindowsEnvVar, ImplementationVisibility::Public); + RELEASE_AND_RETURN(scope, wrapInWindowsEnvProxy(globalObject, object, keyArray, /* syncOSEnv */ true)); +#else + return object; +#endif +} + +#if OS(WINDOWS) +JSC_DEFINE_HOST_FUNCTION(jsNoopEditWindowsEnvVar, (JSGlobalObject*, JSC::CallFrame*)) +{ + return JSValue::encode(jsUndefined()); +} + +JSValue wrapInWindowsEnvProxy(Zig::GlobalObject* globalObject, JSC::JSObject* object, JSC::JSArray* keyArray, bool syncOSEnv) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + auto editWindowsEnvVar = JSC::JSFunction::create(vm, globalObject, 0, String("editWindowsEnvVar"_s), syncOSEnv ? jsEditWindowsEnvVar : jsNoopEditWindowsEnvVar, ImplementationVisibility::Public); JSC::JSFunction* getSourceEvent = JSC::JSFunction::create(vm, globalObject, processObjectInternalsWindowsEnvCodeGenerator(vm), globalObject); RETURN_IF_EXCEPTION(scope, {}); @@ -1154,8 +1177,6 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) } RELEASE_AND_RETURN(scope, result); -#else - return object; -#endif } +#endif } diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 90d0f055e7ee..30600edeb4f2 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -45,6 +45,10 @@ class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { static bool putByIndex(JSC::JSCell*, JSC::JSGlobalObject*, unsigned, JSC::JSValue, bool shouldThrow); static bool defineOwnProperty(JSC::JSObject*, JSC::JSGlobalObject*, JSC::PropertyName, const JSC::PropertyDescriptor&, bool shouldThrow); static bool deleteProperty(JSC::JSCell*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::DeletePropertySlot&); + static bool preventExtensions(JSC::JSObject*, JSC::JSGlobalObject*) + { + return false; + } private: JSEnvironmentVariableMap(JSC::VM& vm, JSC::Structure* structure) @@ -55,6 +59,10 @@ class JSEnvironmentVariableMap final : public JSC::JSNonFinalObject { JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); +#if OS(WINDOWS) +JSC::JSValue wrapInWindowsEnvProxy(Zig::GlobalObject* globalObject, JSC::JSObject* object, JSC::JSArray* keyArray, bool syncOSEnv); +#endif + // Setting TZ must make *existing* Date instances recompute local time. JSC's DateCache // reset only clears shared slots; live DateInstances keep a Ref to DateInstanceData // whose gregorian cache still matches, so walk the heap and invalidate those. diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b5252a9fbe43..17c49db21dd8 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -594,13 +594,35 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, #if OS(WINDOWS) JSC::JSObject* env = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), size >= JSFinalObject::maxInlineCapacity ? JSFinalObject::maxInlineCapacity : size); + JSC::JSArray* keyArray = JSC::constructEmptyArray(globalObject, nullptr, size); + JSValue wrapped; + if (!scope.exception()) [[likely]] { + unsigned keyIndex = 0; + size_t i = 0; + for (auto k : map) { + keyArray->putByIndexInline(globalObject, keyIndex++, jsString(vm, k.key), false); + if (scope.exception()) [[unlikely]] + break; + // Numeric env keys hit putDirectIndex → defineOwnProperty (declares a + // ThrowScope). Seeded values are JSStrings, so this throws only on OOM + // or under a termination already requested for this starting worker. + env->putDirectMayBeIndex(globalObject, JSC::Identifier::fromString(vm, k.key.convertToASCIIUppercase()), strings.at(i++)); + if (scope.exception()) [[unlikely]] + break; + } + if (!scope.exception()) [[likely]] + wrapped = Bun::wrapInWindowsEnvProxy(globalObject, env, keyArray, /* syncOSEnv */ false); + } + // Same contract as the POSIX arm: the exception stays pending for the caller, and + // whatever was built is installed so nothing downstream reads a null env. + JSC::JSObject* installed = scope.exception() ? nullptr : wrapped.getObject(); + globalObject->m_processEnvObject.set(vm, globalObject, installed ? installed : env); #else // Same exotic object as the main thread so writes inside the // worker coerce to string, reject symbol keys, and validate // defineProperty like Node's EnvSetter/EnvDefiner. auto* envStructure = Bun::JSEnvironmentVariableMap::createStructure(vm, globalObject, globalObject->objectPrototype()); JSC::JSObject* env = Bun::JSEnvironmentVariableMap::create(vm, envStructure); -#endif size_t i = 0; for (auto k : map) { // Numeric env keys hit putDirectIndex → defineOwnProperty (declares a @@ -611,6 +633,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, break; } globalObject->m_processEnvObject.set(vm, globalObject, env); +#endif } else if (options.sharedEnvStore) { // worker_threads SHARE_ENV: join the env tree the spawning thread // resolved. Consumed like options.env, and published on the context @@ -3262,6 +3285,10 @@ JSC_DEFINE_HOST_FUNCTION(functionJsGc, extern "C" [[ZIG_EXPORT(nothrow)]] void JSC__JSGlobalObject__addGc(JSC::JSGlobalObject* globalObject) { auto& vm = JSC::getVM(globalObject); + // Also reached from web_worker.rs start_vm before the worker thread takes + // the API lock; putDirectNativeFunction allocates and asserts the lock. + // JSLock is recursive, so this is a no-op on the main path. + JSC::JSLockHolder locker(vm); globalObject->putDirectNativeFunction(vm, globalObject, JSC::Identifier::fromString(vm, "gc"_s), 0, functionJsGc, ImplementationVisibility::Public, JSC::NoIntrinsic, PropertyAttribute::DontEnum | 0); } diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 4a0e24e5280c..05ecc25e13aa 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -75,6 +75,9 @@ #include "JSEnvironmentVariableMap.h" #include +extern "C" bool Bun__Worker__validateExecArgv(WTF::StringImpl* const* argv, size_t len, BunString* outMessage); +extern "C" bool Bun__Worker__validateWorkerNodeOptions(WTF::StringImpl* nodeOptions, BunString* outMessage); + namespace WebCore { using namespace JSC; @@ -295,6 +298,20 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: env.add(key.impl()->isolatedCopy(), str); } + // Only an explicit env's NODE_OPTIONS is validated (Rust skips when + // byte-identical to the parent's): https://github.com/nodejs/node/blob/main/src/node_worker.cc + if (envValue && envValue.isCell()) { + auto nodeOptions = env.find("NODE_OPTIONS"_s); + if (nodeOptions != env.end()) { + BunString invalidNodeOptions = BunStringEmpty; + if (!Bun__Worker__validateWorkerNodeOptions(nodeOptions->value.impl(), &invalidNodeOptions)) { + auto message = makeString("Initiated Worker with invalid NODE_OPTIONS env variable: "_s, invalidNodeOptions.transferToWTFString()); + throwScope.throwException(lexicalGlobalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_INVALID_EXEC_ARGV, message)); + return {}; + } + } + } + options.env.emplace(WTF::move(env)); } } @@ -334,6 +351,13 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: execArgv.append(str); }); RETURN_IF_EXCEPTION(throwScope, {}); + BunString invalidExecArgv = BunStringEmpty; + static_assert(sizeof(WTF::String) == sizeof(WTF::StringImpl*)); + if (!Bun__Worker__validateExecArgv(reinterpret_cast(execArgv.begin()), execArgv.size(), &invalidExecArgv)) { + auto message = makeString("Initiated Worker with invalid execArgv flags: "_s, invalidExecArgv.transferToWTFString()); + throwScope.throwException(lexicalGlobalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_INVALID_EXEC_ARGV, message)); + return {}; + } options.execArgv.emplace(WTF::move(execArgv)); } } diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 2b041687a2c0..8ee72894a73f 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -208,12 +208,17 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker { JSC::JSValue error = JSC::JSValue::decode(errorValue); String messageStr = message->transferToWTFString(); - ErrorEvent::Init init; - init.message = messageStr; - init.error = error; - init.cancelable = false; - init.bubbles = false; - globalObject->globalEventScope->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); + // terminate() can land mid-entry (the rejected entry promise routes here); the pending + // TerminationException is non-clearable and dispatchEvent enters JS, which asserts + // !exception() on entry. postErrorToWorkerObject takes its message-only path in that state. + if (!JSC::getVM(globalObject).hasPendingTerminationException()) { + ErrorEvent::Init init; + init.message = messageStr; + init.error = error; + init.cancelable = false; + init.bubbles = false; + globalObject->globalEventScope->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); + } proxy->postErrorToWorkerObject(*globalObject, messageStr, error); } diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index 1ae3514eac53..bc9a87d7389b 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -502,6 +502,10 @@ bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& // through getters even in NonThrowing mode) nor the `code` read may leave an exception behind. auto& vm = JSC::getVM(&workerGlobalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + // A pending TerminationException survives CLEAR_IF_EXCEPTION, and the clone enters JS, which + // asserts !exception() on entry; the caller falls back to the message-only report. + if (scope.exception()) + return false; auto serialized = SerializedScriptValue::create(workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); CLEAR_IF_EXCEPTION(scope); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 39c8288c3cb5..fd31999ab93f 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -81,6 +81,8 @@ pub struct WebWorker { inherit_exec_argv: bool, unresolved_specifier: Box<[u8]>, preloads: Vec>, + /// `--expose-gc` for this worker; inheriting children read it at their `create()`. + expose_gc: bool, name: bun_core::ZBox, /// `--cpu-prof` on the parent applies to workers that inherit its execArgv (as in node, where /// the flag is per-process); a worker with its own execArgv profiles only if that says so. @@ -374,18 +376,34 @@ impl WebWorker { let parent_ref = unsafe { &*parent }; let store_fd = parent_ref.transpiler.resolver.store_fd; let mut transform_options = (*parent_ref.transpiler.options.transform_options).clone(); - // A worker's own `execArgv` carries node's per-Environment options (parsed with the - // RunCommand param table, hence the hook); without one it inherits the parent's. - let exec_argv: virtual_machine::WorkerExecArgv = if inherit_exec_argv { - Default::default() - } else { - let hooks = runtime_hooks().expect("RuntimeHooks not installed"); - // SAFETY: caller passed valid (ptr,len) borrowed from the C++ WorkerOptions, alive - // for the proxy's lifetime; the hook only reads the slice. - unsafe { - (hooks.parse_worker_exec_argv)(bun_core::ffi::slice(exec_argv_ptr, exec_argv_len)) - } - }; + // A worker's own `execArgv` carries node's per-Environment options (validated and + // honoured by `cli::worker_exec_argv`, hence the hook); without one it inherits the + // parent's. `--expose-gc` chains through inheriting workers, so an inheriting worker takes + // it from its parent worker, or from the process argv when the parent is the main thread. + let hooks = runtime_hooks().expect("RuntimeHooks not installed"); + let (mut exec_argv, expose_gc): (virtual_machine::WorkerExecArgv, bool) = + if inherit_exec_argv { + let expose_gc = match parent_ref.worker_ref() { + Some(parent_worker) => parent_worker.expose_gc, + // SAFETY: `None` reads only process-constant state. + None => unsafe { (hooks.parse_worker_exec_argv)(None) }.expose_gc, + }; + (Default::default(), expose_gc) + } else { + // SAFETY: caller passed valid (ptr,len) borrowed from the C++ WorkerOptions, alive + // for the proxy's lifetime; the hook only reads the slice. + let parsed = unsafe { + (hooks.parse_worker_exec_argv)(Some(bun_core::ffi::slice( + exec_argv_ptr, + exec_argv_len, + ))) + }; + let expose_gc = parsed.expose_gc; + (parsed, expose_gc) + }; + // `--require`/`--import` specifiers join the worker's preload list; `load_preloads` + // resolves them on the worker thread, so a bad path fails at runtime as in node. + preloads.extend(core::mem::take(&mut exec_argv.preloads)); if let Some(allow_addons) = exec_argv.allow_addons { let parent_allows = transform_options.allow_addons.unwrap_or(true); transform_options.allow_addons = Some(parent_allows && allow_addons); @@ -448,6 +466,7 @@ impl WebWorker { inherit_exec_argv, unresolved_specifier: spec_slice.slice().to_vec().into_boxed_slice(), preloads, + expose_gc, name: if name_str.is_empty() { bun_core::ZBox::default() } else { @@ -838,6 +857,10 @@ impl WebWorker { vm_ref.cpu_profiler_config = Some(config); crate::bun_cpu_profiler::start_cpu_profiler(vm_ref.jsc_vm_mut()); } + + if self.expose_gc { + crate::cpp::JSC__JSGlobalObject__addGc(vm_ref.global()); + } } // Publish now (rather than at the end of startVM) so that: diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index f609dfdd4a0b..e7ac89429670 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -314,6 +314,7 @@ pub mod arguments; pub use arguments as Arguments; #[path = "run_command.rs"] pub mod run_command; +pub mod worker_exec_argv; // ─── per-subcommand bodies ─────────────────────────────────────────────────── #[path = "build_command.rs"] diff --git a/src/runtime/cli/worker_exec_argv.rs b/src/runtime/cli/worker_exec_argv.rs new file mode 100644 index 000000000000..32778d0ae771 --- /dev/null +++ b/src/runtime/cli/worker_exec_argv.rs @@ -0,0 +1,570 @@ +//! Worker `execArgv` policy — parity with . + +use std::sync::LazyLock; + +use bun_core::{String as BunString, WTFStringImplExt as _}; +use bun_jsc::virtual_machine::WorkerExecArgv; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ValueMode { + None, + /// Value only via `--flag=value`; a following token is not consumed. + Optional, + /// Value via `--flag=value` or the next token; missing value is an error. + Required, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Policy { + /// Accepted in worker execArgv. + Allow, + /// Rejected in worker execArgv (listed in ERR_WORKER_INVALID_EXEC_ARGV). + Reject, +} + +#[derive(Clone, Copy, Debug)] +pub struct FlagSpec { + pub value: ValueMode, + pub policy: Policy, + /// Accepted inside a worker's explicit `env: { NODE_OPTIONS }` check. + pub env: bool, +} + +const fn spec(value: ValueMode, policy: Policy, env: bool) -> FlagSpec { + FlagSpec { value, policy, env } +} + +const ALLOW: FlagSpec = spec(ValueMode::None, Policy::Allow, true); +const ALLOW_ARG: FlagSpec = spec(ValueMode::Required, Policy::Allow, true); +const ALLOW_NO_ENV: FlagSpec = spec(ValueMode::None, Policy::Allow, false); +const ALLOW_ARG_NO_ENV: FlagSpec = spec(ValueMode::Required, Policy::Allow, false); +const V8_REJECT: FlagSpec = spec(ValueMode::None, Policy::Reject, true); +const V8_REJECT_ARG: FlagSpec = spec(ValueMode::Required, Policy::Reject, true); + +static NODE_FLAGS: &[(&[u8], FlagSpec)] = &[ + (b"--no-warnings", ALLOW), + (b"--trace-warnings", ALLOW), + (b"--pending-deprecation", ALLOW), + (b"--trace-deprecation", ALLOW), + (b"--trace-uncaught", ALLOW), + (b"--redirect-warnings", ALLOW_ARG), + (b"--disable-warning", ALLOW_ARG), + (b"--input-type", ALLOW_ARG), + (b"--experimental-vm-modules", ALLOW), + (b"--frozen-intrinsics", ALLOW), + (b"--enable-source-maps", ALLOW), + (b"--experimental-detect-module", ALLOW), + (b"--no-experimental-detect-module", ALLOW), + (b"--experimental-strip-types", ALLOW), + (b"--no-experimental-strip-types", ALLOW), + (b"--experimental-loader", ALLOW_ARG), + (b"--experimental-require-module", ALLOW), + (b"--no-experimental-require-module", ALLOW), + (b"--experimental-import-meta-resolve", ALLOW), + (b"--experimental-websocket", ALLOW), + (b"--no-experimental-websocket", ALLOW), + (b"--experimental-sqlite", ALLOW), + (b"--no-experimental-sqlite", ALLOW), + (b"--experimental-eventsource", ALLOW), + (b"--no-experimental-eventsource", ALLOW), + (b"--experimental-webstorage", ALLOW), + (b"--experimental-wasm-modules", ALLOW), + (b"--no-experimental-fetch", ALLOW), + (b"--no-experimental-global-webcrypto", ALLOW), + (b"--no-experimental-global-customevent", ALLOW), + (b"--experimental-async-context-frame", ALLOW), + (b"--no-experimental-async-context-frame", ALLOW), + (b"--experimental-network-inspection", ALLOW), + (b"--experimental-worker-inspection", ALLOW), + (b"--experimental-test-coverage", ALLOW), + (b"--test-only", ALLOW), + (b"--test-name-pattern", ALLOW_ARG), + (b"--test-skip-pattern", ALLOW_ARG), + (b"--test-reporter", ALLOW_ARG), + (b"--test-reporter-destination", ALLOW_ARG), + (b"--insecure-http-parser", ALLOW), + (b"--no-global-search-paths", ALLOW), + (b"--no-addons", ALLOW), + (b"--disable-proto", ALLOW_ARG), + (b"--no-force-async-hooks-checks", ALLOW), + (b"--force-async-hooks-checks", ALLOW), + (b"--force-node-api-uncaught-exceptions-policy", ALLOW), + (b"--force-context-aware", ALLOW), + (b"--napi-modules", ALLOW), + (b"--trace-sync-io", ALLOW), + (b"--track-heap-objects", ALLOW), + (b"--verify-base-objects", ALLOW), + (b"--report-uncaught-exception", ALLOW), + (b"--report-on-signal", ALLOW), + (b"--report-on-fatalerror", ALLOW), + (b"--report-signal", ALLOW_ARG), + (b"--experimental-report", ALLOW), + (b"--heapsnapshot-signal", ALLOW_ARG), + (b"--heapsnapshot-near-heap-limit", ALLOW_ARG), + (b"--diagnostic-dir", ALLOW_ARG), + (b"--tls-min-v1.0", ALLOW), + (b"--tls-min-v1.1", ALLOW), + (b"--tls-min-v1.2", ALLOW), + (b"--tls-min-v1.3", ALLOW), + (b"--tls-max-v1.2", ALLOW), + (b"--tls-max-v1.3", ALLOW), + (b"--permission", ALLOW), + (b"--experimental-permission", ALLOW), + (b"--allow-fs-read", ALLOW_ARG), + (b"--allow-fs-write", ALLOW_ARG), + (b"--allow-child-process", ALLOW), + (b"--allow-worker", ALLOW), + (b"--allow-wasi", ALLOW), + (b"--allow-addons", ALLOW), + (b"--inspect-port", ALLOW_ARG), + (b"--debug-port", ALLOW_ARG), + (b"--inspect-publish-uid", ALLOW_ARG), + (b"--prof-process", ALLOW), + (b"--heap-prof-interval", ALLOW_ARG), + (b"--tls-keylog", ALLOW_ARG), + (b"-C", ALLOW_ARG), + (b"--test", ALLOW_NO_ENV), + (b"--check", ALLOW_NO_ENV), + (b"--interactive", ALLOW_NO_ENV), + (b"--env-file", ALLOW_ARG_NO_ENV), + (b"--env-file-if-exists", ALLOW_ARG_NO_ENV), + (b"--watch-path", ALLOW_ARG_NO_ENV), + (b"--max-old-space-size", V8_REJECT_ARG), + (b"--max-semi-space-size", V8_REJECT_ARG), + (b"--stack-size", V8_REJECT_ARG), + (b"--jitless", V8_REJECT), + (b"--disallow-code-generation-from-strings", V8_REJECT), + (b"--perf-basic-prof", V8_REJECT), + (b"--perf-basic-prof-only-functions", V8_REJECT), + (b"--perf-prof", V8_REJECT), + (b"--perf-prof-unwinding-info", V8_REJECT), + (b"--interpreted-frames-native-stack", V8_REJECT), + (b"--abort-on-uncaught-exception", V8_REJECT), + (b"--huge-max-old-generation-size", V8_REJECT), +]; + +static BUN_TABLE_REJECTS: &[&[u8]] = &[ + b"--title", + b"--zero-fill-buffers", + b"--use-openssl-ca", + b"--use-bundled-ca", +]; + +static ENV_DISALLOWED: &[&[u8]] = &[b"--eval", b"-e", b"--print", b"-p"]; + +fn table_map() -> &'static bun_collections::StringArrayHashMap { + static MAP: LazyLock> = LazyLock::new(|| { + let mut map = bun_collections::StringArrayHashMap::::default(); + let mut put = |key: Vec, spec: FlagSpec| { + bun_core::handle_oom(map.put(&key, spec)); + }; + for param in crate::cli::arguments::AUTO_PARAMS.iter() { + let value = match param.takes_value { + bun_clap::Values::None => ValueMode::None, + bun_clap::Values::OneOptional => ValueMode::Optional, + bun_clap::Values::One | bun_clap::Values::Many => ValueMode::Required, + }; + let mut names: [Option>; 2] = [None, None]; + if let Some(long) = param.names.long { + let mut k = Vec::with_capacity(2 + long.len()); + k.extend_from_slice(b"--"); + k.extend_from_slice(long); + names[0] = Some(k); + } + if let Some(short) = param.names.short { + names[1] = Some(vec![b'-', short]); + } + for key in names.into_iter().flatten() { + let policy = if BUN_TABLE_REJECTS.contains(&&key[..]) { + Policy::Reject + } else { + Policy::Allow + }; + let env = policy == Policy::Allow && !ENV_DISALLOWED.contains(&&key[..]); + put(key, FlagSpec { value, policy, env }); + } + } + for &(name, spec) in NODE_FLAGS { + put(name.to_vec(), spec); + } + for &(from, to) in crate::cli::arguments::NODE_SHORT_ALIASES { + if let Some(&s) = map.get(to) { + bun_core::handle_oom(map.put(from, s)); + } + } + map + }); + &MAP +} + +pub fn collect_process_exec_argv_tokens() -> Vec> { + fn short_takes_value(c: u8) -> Option { + crate::cli::arguments::AUTO_PARAMS + .iter() + .find(|p| p.names.short == Some(c)) + .map(|p| p.takes_value) + } + fn push_normalized_short_token(arg: &[u8], out: &mut Vec>) -> Option { + let mut flags: Vec = Vec::new(); + let mut value: Option<&[u8]> = None; + let mut needs_next_value = false; + let mut j = 1usize; + while j < arg.len() { + let takes = short_takes_value(arg[j])?; + let next = j + 1; + match takes { + bun_clap::Values::None => { + if next < arg.len() && arg[next] == b'=' { + return None; + } + flags.push(arg[j]); + j = next; + } + bun_clap::Values::OneOptional => { + flags.push(arg[j]); + break; + } + bun_clap::Values::One | bun_clap::Values::Many => { + flags.push(arg[j]); + if next >= arg.len() { + needs_next_value = true; + break; + } + let v = if arg[next] == b'=' { + &arg[next + 1..] + } else { + &arg[next..] + }; + value = Some(v); + break; + } + } + } + for &f in &flags { + out.push(vec![b'-', f]); + } + if let Some(v) = value { + out.push(v.to_vec()); + } + Some(needs_next_value) + } + + static TAKES_VALUE: LazyLock = LazyLock::new(|| { + let mut set = bun_collections::StringSet::new(); + for param in crate::cli::arguments::AUTO_PARAMS.iter() { + if matches!( + param.takes_value, + bun_clap::Values::One | bun_clap::Values::Many + ) { + if let Some(name) = param.names.long { + let mut k = Vec::with_capacity(2 + name.len()); + k.extend_from_slice(b"--"); + k.extend_from_slice(name); + bun_core::handle_oom(set.insert(&k)); + } + if let Some(name) = param.names.short { + bun_core::handle_oom(set.insert(&[b'-', name])); + } + } + } + set + }); + + let argv = bun_core::argv(); + let mut out = Vec::with_capacity(argv.len().saturating_sub(1)); + let mut seen_run = false; + let mut prev_takes_value = false; + let mut iter = argv.iter(); + let _ = iter.next(); + for arg in iter { + let arg: &[u8] = arg; + if prev_takes_value { + out.push(arg.to_vec()); + prev_takes_value = false; + continue; + } + if arg.len() >= 1 && arg[0] == b'-' { + let node_alias_to = crate::cli::arguments::NODE_SHORT_ALIASES + .iter() + .find_map(|(from, to)| (*from == arg).then_some(*to)); + let normalized = if node_alias_to.is_none() && arg.len() > 2 && arg[1] != b'-' { + push_normalized_short_token(arg, &mut out) + } else { + None + }; + prev_takes_value = match normalized { + Some(needs_next) => needs_next, + None => { + out.push(arg.to_vec()); + TAKES_VALUE.contains(arg) + || (!seen_run && node_alias_to.is_some_and(|to| TAKES_VALUE.contains(to))) + } + }; + continue; + } + if !seen_run && arg == b"run" { + seen_run = true; + continue; + } + break; + } + out +} + +fn normalized(name: &[u8]) -> Vec { + name.iter() + .map(|&b| if b == b'_' { b'-' } else { b }) + .collect() +} + +fn split_token(tok: &[u8]) -> (&[u8], Option<&[u8]>) { + if tok.starts_with(b"--") { + if let Some(pos) = bun_core::strings::index_of_char_usize(tok, b'=') { + return (&tok[..pos], Some(&tok[pos + 1..])); + } + } + (tok, None) +} + +#[derive(Default)] +pub struct ScanOutcome { + pub honored: WorkerExecArgv, + /// ` requires an argument` entries; take precedence over `invalid` + pub errors: Vec>, + /// Raw rejected tokens. + pub invalid: Vec>, +} + +impl ScanOutcome { + pub fn message(&self) -> Option> { + let list = if !self.errors.is_empty() { + &self.errors + } else if !self.invalid.is_empty() { + &self.invalid + } else { + return None; + }; + Some(list.join(&b", "[..])) + } +} + +pub fn scan_exec_argv>(tokens: &[T]) -> ScanOutcome { + let map = table_map(); + let mut out = ScanOutcome::default(); + let mut saw_no_addons = false; + let mut i = 0usize; + while i < tokens.len() { + let tok = tokens[i].as_ref(); + i += 1; + if tok == b"--" || tok == b"-" || !tok.starts_with(b"-") { + break; + } + let (name, eq_value) = split_token(tok); + let key = normalized(name); + let Some(spec) = map.get(&key[..]) else { + out.invalid.push(tok.to_vec()); + continue; + }; + if spec.policy == Policy::Reject { + out.invalid.push(tok.to_vec()); + if spec.value == ValueMode::Required && eq_value.is_none() && i < tokens.len() { + i += 1; + } + continue; + } + let value: Option> = match spec.value { + ValueMode::Required => match eq_value { + Some(v) => Some(v.to_vec()), + None => { + if i < tokens.len() { + let v = tokens[i].as_ref().to_vec(); + i += 1; + Some(v) + } else { + let mut err = key.clone(); + err.extend_from_slice(b" requires an argument"); + out.errors.push(err); + continue; + } + } + }, + _ => eq_value.map(<[u8]>::to_vec), + }; + match &key[..] { + b"--no-addons" => saw_no_addons = true, + b"--use-system-ca" => out.honored.use_system_ca = Some(true), + b"--no-use-system-ca" => out.honored.use_system_ca = Some(false), + b"--expose-gc" => out.honored.expose_gc = true, + b"--cpu-prof" => out.honored.cpu_prof = true, + b"--cpu-prof-md" => out.honored.cpu_prof_md = true, + b"--cpu-prof-interval" => { + out.honored.cpu_prof_interval = value + .as_deref() + .and_then(|v| std::str::from_utf8(v).ok()) + .and_then(|s| s.parse().ok()); + } + b"--cpu-prof-name" => { + out.honored.cpu_prof_name = value + .as_deref() + .map(crate::cli::arguments::replace_pid_placeholder); + } + b"--cpu-prof-dir" => { + out.honored.cpu_prof_dir = value.map(Vec::into_boxed_slice); + } + b"--require" | b"--preload" | b"-r" | b"--import" => { + if let Some(v) = value { + out.honored.preloads.push(v.into_boxed_slice()); + } + } + _ => {} + } + } + out.honored.allow_addons = Some(!saw_no_addons); + out +} + +pub fn scan_process_exec_argv() -> WorkerExecArgv { + static CACHED: LazyLock = LazyLock::new(|| { + let mut tokens: Vec> = Vec::new(); + let vm = bun_jsc::virtual_machine::VirtualMachine::get(); + if let Some(graph) = vm.standalone_module_graph { + if let Some(opts) = bun_core::env_var::BUN_OPTIONS.get() { + let mut parsed: Vec> = + vec![ as bun_core::OptionsEnvArg>::from_slice(b"")]; + bun_core::append_options_env(opts, &mut parsed); + for t in &parsed[1..] { + let t = t.as_bytes(); + tokens.push(t.strip_suffix(b"\0").unwrap_or(t).to_vec()); + } + } + // Same tokenizer as `create_exec_argv`, so this scan sees exactly the + // tokens `process.execArgv` exposes. + for token in bun_core::strings::tokenize_any(graph.compile_exec_argv(), b" \t\n\r") { + tokens.push(token.to_vec()); + } + } else { + tokens = collect_process_exec_argv_tokens(); + } + let mut outcome = scan_exec_argv(&tokens); + outcome.honored.preloads.clear(); + outcome.honored.cpu_prof = false; + outcome.honored.cpu_prof_md = false; + outcome.honored.cpu_prof_interval = None; + outcome.honored.cpu_prof_name = None; + outcome.honored.cpu_prof_dir = None; + outcome.honored + }); + CACHED.clone() +} + +/// `WTF::StringImpl*[]` → owned UTF-8 tokens (nulls skipped); shared by validation + honoring. +/// # Safety +/// Each non-null entry is a live `WTF::StringImpl*` owned by the caller. +pub(crate) unsafe fn owned_tokens(exec_argv: &[bun_core::WTFStringImpl]) -> Vec> { + let mut tokens = Vec::with_capacity(exec_argv.len()); + for &s in exec_argv { + if s.is_null() { + continue; + } + // SAFETY: per fn contract — `s` is a live `WTFStringImpl*`. + tokens.push(unsafe { &*s }.to_owned_slice_z().as_bytes().to_vec()); + } + tokens +} + +/// Validate a worker's explicit `execArgv` (JSWorker.cpp); writes the ERR_WORKER_INVALID_EXEC_ARGV tail on reject. +/// # Safety +/// `argv`/`len` as in [`owned_tokens`]; `out_message` is a valid out-param. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__Worker__validateExecArgv( + argv: *const bun_core::WTFStringImpl, + len: usize, + out_message: *mut BunString, +) -> bool { + // SAFETY: per fn contract. + let tokens = unsafe { owned_tokens(bun_core::ffi::slice(argv, len)) }; + match scan_exec_argv(&tokens).message() { + None => true, + Some(msg) => { + // SAFETY: per fn contract — valid out-param. + unsafe { *out_message = BunString::clone_utf8(&msg) }; + false + } + } +} + +/// Validate a worker env's `NODE_OPTIONS` — skipped when byte-equal to the parent's (node_worker.cc). +/// # Safety +/// `node_options` is a live `WTF::StringImpl*`/null; `out_message` valid; thread has a live VM. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Bun__Worker__validateWorkerNodeOptions( + node_options: bun_core::WTFStringImpl, + out_message: *mut BunString, +) -> bool { + if node_options.is_null() { + return true; + } + // SAFETY: per fn contract. + let value = unsafe { &*node_options }.to_owned_slice_z(); + let value = value.as_bytes(); + + let vm = bun_jsc::virtual_machine::VirtualMachine::get(); + if let Some(parent) = vm.env_loader().map.get(b"NODE_OPTIONS") { + if parent == value { + return true; + } + } + + let mut tokens: Vec> = + vec![ as bun_core::OptionsEnvArg>::from_slice(b"")]; + bun_core::append_options_env(value, &mut tokens); + + let fail = |msg: Vec| { + // SAFETY: per fn contract — valid out-param. + unsafe { *out_message = BunString::clone_utf8(&msg) }; + false + }; + let not_allowed = |name: &[u8], had_eq: bool| { + let mut msg = name.to_vec(); + if had_eq { + msg.push(b'='); + } + msg.extend_from_slice(b" is not allowed in NODE_OPTIONS"); + msg + }; + + let map = table_map(); + let mut i = 1usize; + while i < tokens.len() { + // `OptionsEnvArg for Box` keeps the trailing NUL in the slice + // metadata (see util.rs) — strip it before classifying. + let tok = tokens[i].as_bytes(); + let tok = tok.strip_suffix(b"\0").unwrap_or(tok); + i += 1; + if !tok.starts_with(b"-") || tok == b"-" || tok == b"--" { + continue; + } + let (tok, glued_value) = match tok.iter().position(u8::is_ascii_whitespace) { + Some(pos) if tok.starts_with(b"--") => (&tok[..pos], true), + _ => (tok, false), + }; + let (name, eq_value) = split_token(tok); + let key = normalized(name); + let spec = match map.get(&key[..]) { + Some(s) if s.env => s, + _ => return fail(not_allowed(name, eq_value.is_some())), + }; + if spec.value == ValueMode::Required && !glued_value && eq_value.is_none() { + // Node pops the next token unconditionally (no leading-dash + // check), matching scan_exec_argv above. + if tokens.get(i).is_some() { + i += 1; + } else { + let mut msg = key; + msg.extend_from_slice(b" requires an argument"); + return fail(msg); + } + } + } + true +} diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 4ccb77ac97cf..2aa73f49ca96 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -20,7 +20,6 @@ //! 4. `__bun_get_vm_ctx` / `__bun_stdio_blob_store_new` / //! `__bun_http_sync_download_*` — low-tier extern impls. -use bun_core::WTFStringImplExt as _; use bun_options_types::LoaderExt as _; use core::cell::Cell; use core::ffi::c_void; @@ -1590,80 +1589,18 @@ unsafe fn apply_standalone_runtime_flags( crate::run_main::apply_standalone_runtime_flags(unsafe { &mut *transpiler }, graph); } -/// Parse a Worker's `execArgv`; scans argv directly since `ArgIter<'static>` would leak the UTF-8 copies. +/// Worker `execArgv` → honoured subset (`None` = inherit from process argv). /// # Safety -/// Each `WTFStringImpl` in `exec_argv` is a live WTF string kept alive for the worker's lifetime. +/// Each `WTFStringImpl` in `exec_argv` is a live WTF string owned by C++ `Worker::create`. unsafe fn parse_worker_exec_argv( - exec_argv: &[bun_core::WTFStringImpl], + exec_argv: Option<&[bun_core::WTFStringImpl]>, ) -> bun_jsc::virtual_machine::WorkerExecArgv { - use crate::cli::arguments::replace_pid_placeholder; - enum Pending { - None, - Interval, - Name, - Dir, - } - let mut out = bun_jsc::virtual_machine::WorkerExecArgv::default(); - let mut no_addons = false; - let mut pending = Pending::None; - let parse_interval = |v: &[u8]| std::str::from_utf8(v).ok().and_then(|s| s.parse().ok()); - for &arg in exec_argv { - if arg.is_null() { - continue; - } - // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. - let owned = unsafe { &*arg }.to_owned_slice_z(); - let bytes = owned.as_bytes(); - match core::mem::replace(&mut pending, Pending::None) { - Pending::None => {} - Pending::Interval => { - out.cpu_prof_interval = parse_interval(bytes); - continue; - } - Pending::Name => { - out.cpu_prof_name = Some(replace_pid_placeholder(bytes)); - continue; - } - Pending::Dir => { - out.cpu_prof_dir = Some(bytes.into()); - continue; - } - } - // execArgv holds no positionals: a bare token is the value of a flag this parser doesn't model - // (`-r ./preload.js`, `--conditions x`), so skip it rather than ending the scan. - if bytes.first() != Some(&b'-') { - continue; - } - if bytes == b"--" { - break; - } - if bytes == b"--no-addons" { - no_addons = true; - } else if bytes == b"--use-system-ca" { - out.use_system_ca = Some(true); - } else if bytes == b"--no-use-system-ca" { - out.use_system_ca = Some(false); - } else if bytes == b"--cpu-prof" { - out.cpu_prof = true; - } else if bytes == b"--cpu-prof-md" { - out.cpu_prof_md = true; - } else if bytes == b"--cpu-prof-interval" { - pending = Pending::Interval; - } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-interval=") { - out.cpu_prof_interval = parse_interval(v); - } else if bytes == b"--cpu-prof-name" { - pending = Pending::Name; - } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-name=") { - out.cpu_prof_name = Some(replace_pid_placeholder(v)); - } else if bytes == b"--cpu-prof-dir" { - pending = Pending::Dir; - } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-dir=") { - out.cpu_prof_dir = Some(v.into()); - } - } - // Override `allow_addons` unconditionally. - out.allow_addons = Some(!no_addons); - out + let Some(exec_argv) = exec_argv else { + return crate::cli::worker_exec_argv::scan_process_exec_argv(); + }; + // SAFETY: per fn contract. + let tokens = unsafe { crate::cli::worker_exec_argv::owned_tokens(exec_argv) }; + crate::cli::worker_exec_argv::scan_exec_argv(&tokens).honored } /// `jsc.API.cron.CronJob.clearAllForVM(vm, .teardown)` — diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 079c2c0e32fa..3505b5bc5c0c 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -304,10 +304,13 @@ mod _impl { return JSValue::create_empty_array(global_object, 0); } - let argv = bun_core::argv(); + let tokens = crate::cli::worker_exec_argv::collect_process_exec_argv_tokens(); // `defer args.deinit()` + `defer for args |*a| a.deref()` - let mut args = scopeguard::guard( - Vec::::with_capacity(argv.len().saturating_sub(1)), + let args = scopeguard::guard( + tokens + .iter() + .map(|t| BunString::clone_utf8(t)) + .collect::>(), |v| { for a in &v { a.deref(); @@ -315,69 +318,6 @@ mod _impl { }, ); - let mut seen_run = false; - let mut prev: Option<&[u8]> = None; - - // we re-parse the process argv to extract execArgv, since this is a very uncommon operation - // it isn't worth doing this as a part of the CLI - let mut iter = argv.iter(); - let _ = iter.next(); // skip argv[0] - for arg in iter { - // emulate `defer prev = arg` by setting at end of each iteration body - let arg: &[u8] = arg; - - if arg.len() >= 1 && arg[0] == b'-' { - args.push(BunString::clone_utf8(arg)); - prev = Some(arg); - continue; - } - - if !seen_run && arg == b"run" { - seen_run = true; - prev = Some(arg); - continue; - } - - // A set of execArgv args consume an extra argument, so we do not want to - // confuse these with script names. - // Build the set lazily at runtime from the `AUTO_PARAMS` table: - // `--long` / `-s` for every param with a value. - static MAP: std::sync::LazyLock = - std::sync::LazyLock::new(|| { - let mut set = bun_collections::StringSet::new(); - for param in crate::cli::arguments::AUTO_PARAMS.iter() { - if param.takes_value != bun_clap::Values::None { - if let Some(name) = param.names.long { - let mut k = Vec::with_capacity(2 + name.len()); - k.extend_from_slice(b"--"); - k.extend_from_slice(name); - bun_core::handle_oom(set.insert(&k)); - } - if let Some(name) = param.names.short { - bun_core::handle_oom(set.insert(&[b'-', name])); - } - } - } - set - }); - - if let Some(p) = prev { - let takes_value = MAP.contains(p) - || (!seen_run - && crate::cli::arguments::NODE_SHORT_ALIASES - .iter() - .any(|(from, to)| *from == p && MAP.contains(to))); - if takes_value { - args.push(BunString::clone_utf8(arg)); - prev = Some(arg); - continue; - } - } - - // we hit the script name - break; - } - bun_string_jsc::to_js_array(global_object, &args) } diff --git a/test/bundler/compile-argv.test.ts b/test/bundler/compile-argv.test.ts index c40245f395db..f9e300493eec 100644 --- a/test/bundler/compile-argv.test.ts +++ b/test/bundler/compile-argv.test.ts @@ -48,6 +48,31 @@ describe("bundler", () => { }, }); + // An inheriting worker honours --compile-exec-argv through the same token stream + // process.execArgv exposes (the explicit round-trip must agree with it). + itBundled("compile/CompileExecArgvWorkerInherit", { + compile: { + execArgv: ["--expose-gc", "--smol"], + }, + backend: "cli", + files: { + "/entry.ts": /* js */ ` + const { Worker } = require("node:worker_threads"); + const probe = "require('worker_threads').parentPort.postMessage(typeof globalThis.gc)"; + const report = (label, worker) => + new Promise(resolve => worker.once("message", value => resolve(label + ":" + value))); + const inherit = new Worker(probe, { eval: true }); + const explicit = new Worker(probe, { eval: true, execArgv: process.execArgv }); + const results = await Promise.all([report("inherit", inherit), report("explicit", explicit)]); + console.log("execArgv:", JSON.stringify(process.execArgv)); + console.log(results.join(" ")); + `, + }, + run: { + stdout: /execArgv: \["--expose-gc","--smol"\]\ninherit:function explicit:function/, + }, + }); + // Test that exec argv options don't leak into process.argv when no user arguments are provided itBundled("compile/CompileExecArgvNoLeak", { compile: { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index d4ed48872546..fa44b0fabfa3 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -6,6 +6,62 @@ import { basename, join, resolve } from "path"; const process_sleep = resolve(import.meta.dir, "process-sleep.js"); +it.skipIf(!isWindows)("a rejected process.env defineProperty leaves no phantom key", () => { + const key = "BUN_TEST_PHANTOM_DEFINE"; + expect(key in process.env).toBe(false); + expect(() => Object.defineProperty(process.env, key, { value: "42" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_OBJECT_DEFINE_PROPERTY" }), + ); + expect(Reflect.ownKeys(process.env)).not.toContain(key); + expect(Bun.inspect(process.env)).not.toContain(key); + try { + Object.defineProperty(process.env, key, { + value: "42", + writable: true, + enumerable: true, + configurable: true, + }); + expect(process.env[key]).toBe("42"); + expect(Reflect.ownKeys(process.env)).toContain(key); + } finally { + delete process.env[key]; + } +}); + +it.skipIf(!isWindows)( + "process.env defineProperty enumerates special-accessor keys and rejects accessor descriptors", + async () => { + const env = { ...bunEnv }; + for (const k of Object.keys(env)) if (/^(https?|no)_proxy$/i.test(k)) delete env[k]; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const key = "HTTP_PROXY"; + Object.defineProperty(process.env, key, { value: "http://x", writable: true, enumerable: true, configurable: true }); + const inKeys = Reflect.ownKeys(process.env).includes(key); + const spread = { ...process.env }[key]; + // Accessor descriptors are rejected like node's EnvDefiner. + let accessor; + try { + Object.defineProperty(process.env, key, { get: () => 42, configurable: true }); + } catch (e) { + accessor = e.code; + } + console.log(JSON.stringify({ inKeys, spread, accessor }));`, + ], + env, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ + out: { inKeys: true, spread: "http://x", accessor: "ERR_INVALID_OBJECT_DEFINE_PROPERTY" }, + stderr: "", + exitCode: 0, + }); + }, +); + /** * Helper function to run inline fixture code and return stdout and exit code */ @@ -146,6 +202,12 @@ it("process.env defineProperty matches assignment semantics", () => { }), ).toThrow(TypeError); + expect(() => Object.defineProperty(process.env, Symbol("env"), {})).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_OBJECT_DEFINE_PROPERTY", + }), + ); + // ...a data descriptor without a [[Value]] is rejected... expect(() => Object.defineProperty(process.env, "NO_VALUE_DESCRIPTOR", { @@ -171,6 +233,27 @@ it("process.env defineProperty matches assignment semantics", () => { expect(process.env[""]).toBeUndefined(); }); +it("process.env refuses [[PreventExtensions]] like node", () => { + for (const op of ["preventExtensions", "freeze", "seal"]) { + let err; + try { + Object[op](process.env); + } catch (e) { + err = e; + } + expect(err?.name).toBe("TypeError"); + expect(err?.code).toBeUndefined(); + } + expect(Object.isExtensible(process.env)).toBe(true); + const key = "BUN_TEST_STILL_EXTENSIBLE"; + try { + process.env[key] = "yes"; + expect(process.env[key]).toBe("yes"); + } finally { + delete process.env[key]; + } +}); + it("process.env.TZ writes inside a worker do not change the main thread's timezone", async () => { // Node does not intercept TZ in workers (only RealEnvStore::Set calls // DateTimeConfigurationChangeNotification, and every worker env is a @@ -1550,6 +1633,7 @@ it("process.execArgv", async () => { ["index.ts --bun -a -b -c", [], ["--bun", "-a", "-b", "-c"]], ["--bun index.ts index.ts", ["--bun"], ["index.ts"]], ["run -e bruh -b index.ts foo -a -b -c", ["-e", "bruh", "-b"], ["foo", "-a", "-b", "-c"]], + ["--define -d:1 index.ts", ["--define", "-d:1"], []], ]; for (const [cmd, execArgv, argv] of fixtures) { diff --git a/test/js/node/test/parallel/test-worker-process-env.js b/test/js/node/test/parallel/test-worker-process-env.js new file mode 100644 index 000000000000..f43c2affa1f4 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-process-env.js @@ -0,0 +1,67 @@ +'use strict'; +const common = require('../common'); +const child_process = require('child_process'); +const assert = require('assert'); +const { Worker, workerData } = require('worker_threads'); + +// Test for https://github.com/nodejs/node/issues/24947. + +if (!workerData && process.argv[2] !== 'child') { + process.env.SET_IN_PARENT = 'set'; + assert.strictEqual(process.env.SET_IN_PARENT, 'set'); + + new Worker(__filename, { workerData: 'runInWorker' }) + .on('exit', common.mustCall(() => { + // Env vars from the child thread are not set globally. + assert.strictEqual(process.env.SET_IN_WORKER, undefined); + })); + + process.env.SET_IN_PARENT_AFTER_CREATION = 'set'; + + new Worker(__filename, { + workerData: 'resetEnv', + env: { 'MANUALLY_SET': true } + }); + + assert.throws(() => { + new Worker(__filename, { env: 42 }); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "options.env" property must be of type object or ' + + 'one of undefined, null, or worker_threads.SHARE_ENV. Received type ' + + 'number (42)' + }); +} else if (workerData === 'runInWorker') { + // Env vars from the parent thread are inherited. + assert.strictEqual(process.env.SET_IN_PARENT, 'set'); + assert.strictEqual(process.env.SET_IN_PARENT_AFTER_CREATION, undefined); + process.env.SET_IN_WORKER = 'set'; + assert.strictEqual(process.env.SET_IN_WORKER, 'set'); + + assert.throws( + () => { + Object.defineProperty(process.env, 'DEFINED_IN_WORKER', { + value: 42 + }); + }, + { + code: 'ERR_INVALID_OBJECT_DEFINE_PROPERTY', + name: 'TypeError', + message: '\'process.env\' only accepts a configurable, ' + + 'writable, and enumerable data descriptor' + } + ); + + + const { stderr } = + child_process.spawnSync(process.execPath, [__filename, 'child']); + assert.strictEqual(stderr.toString(), '', stderr.toString()); +} else if (workerData === 'resetEnv') { + assert.deepStrictEqual(Object.keys(process.env), ['MANUALLY_SET']); + assert.strictEqual(process.env.MANUALLY_SET, 'true'); +} else { + // Child processes inherit the parent's env, even from Workers. + assert.strictEqual(process.env.SET_IN_PARENT, 'set'); + assert.strictEqual(process.env.SET_IN_WORKER, 'set'); +} diff --git a/test/js/node/test/parallel/test-worker-stdio-from-preload-module.js b/test/js/node/test/parallel/test-worker-stdio-from-preload-module.js new file mode 100644 index 000000000000..20a72c04adb4 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-stdio-from-preload-module.js @@ -0,0 +1,20 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const { Worker } = require('worker_threads'); +const assert = require('assert'); + +// Regression test for https://github.com/nodejs/node/issues/31777: +// stdio operations coming from preload modules should count towards the +// ref count of the internal communication port on the Worker side. + +for (let i = 0; i < 10; i++) { + const w = new Worker('console.log("B");', { + execArgv: ['--require', fixtures.path('printA.js')], + eval: true, + stdout: true + }); + w.on('exit', common.mustCall(() => { + assert.strictEqual(w.stdout.read(w.stdout.readableLength).toString(), 'A\nB\n'); + })); +} diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 35bdaba133dc..2acb90a2aaa9 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -400,6 +400,346 @@ describe("execArgv option", async () => { await run('["--no-warnings"]', '["--no-warnings"]\n'); }); // TODO(@190n) get our handling of non-string array elements in line with Node's + + it("throws ERR_WORKER_INVALID_EXEC_ARGV for unknown flags", () => { + let err: any; + try { + new Worker("1", { eval: true, execArgv: ["--foo"] }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe("Initiated Worker with invalid execArgv flags: --foo"); + }); + + it("lists every invalid flag in order", () => { + let err: any; + try { + new Worker("1", { eval: true, execArgv: ["--foo", "--bar", "--title=blah"] }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe("Initiated Worker with invalid execArgv flags: --foo, --bar, --title=blah"); + }); + + it("a rejected flag consumes its value token, so later flags are still reported", () => { + let err: any; + try { + new Worker("1", { eval: true, execArgv: ["--max-old-space-size", "4096", "--foo"] }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe("Initiated Worker with invalid execArgv flags: --max-old-space-size, --foo"); + }); + + it("reports a missing required value", () => { + let err: any; + try { + new Worker("1", { eval: true, execArgv: ["--redirect-warnings"] }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe("Initiated Worker with invalid execArgv flags: --redirect-warnings requires an argument"); + }); + + it("a required value consumes the next token even when it starts with a dash", async () => { + // node pops the next token unconditionally, so the dash-prefixed token is + // the value, not a new flag; both validators must agree. + const w1 = new Worker("1", { eval: true, execArgv: ["--redirect-warnings", "--no-warnings"] }); + const w2 = new Worker("1", { eval: true, env: { NODE_OPTIONS: "--redirect-warnings --no-warnings" } }); + await Promise.all([once(w1, "exit"), once(w2, "exit")]); + }); + + it("stops validating at the first positional, like node", async () => { + // node accepts these: parsing stops at `--`/the first non-flag token. + const w1 = new Worker("1", { eval: true, execArgv: ["foo.js"] }); + const w2 = new Worker("1", { eval: true, execArgv: ["--", "--not-a-flag"] }); + await Promise.all([once(w1, "exit"), once(w2, "exit")]); + }); + + it("throws for invalid NODE_OPTIONS in an explicit env", () => { + let err: any; + try { + new Worker("1", { eval: true, env: { NODE_OPTIONS: "--nonexistent-options" } }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe( + "Initiated Worker with invalid NODE_OPTIONS env variable: --nonexistent-options is not allowed in NODE_OPTIONS", + ); + }); + + it("accepts Bun run-surface flags in execArgv and NODE_OPTIONS", async () => { + const workers = [ + new Worker("1", { eval: true, execArgv: ["--bun"] }), + new Worker("1", { eval: true, env: { NODE_OPTIONS: "--bun" } }), + new Worker("1", { eval: true, execArgv: ["--silent"] }), + new Worker("1", { eval: true, execArgv: ["--cwd", process.cwd()] }), + ]; + await Promise.all(workers.map(w => once(w, "exit"))); + // A Bun flag does not disable validation of the rest of the value. + let err: any; + try { + new Worker("1", { eval: true, env: { NODE_OPTIONS: "--bun --nonexistent-options" } }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe( + "Initiated Worker with invalid NODE_OPTIONS env variable: --nonexistent-options is not allowed in NODE_OPTIONS", + ); + }); + + it("SHARE_ENV process.env validates descriptors like node", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV } = require("worker_threads"); + new Worker( + 'const out = {};' + + 'try { Object.defineProperty(process.env, "BUN_TEST_SHARE_DEFINE", { writable: false }); out.partial = null; }' + + 'catch (e) { out.partial = e.code; }' + + 'try { Object.defineProperty(process.env, Symbol("s"), { value: "v", writable: true, enumerable: true, configurable: true }); out.symbol = null; }' + + 'catch (e) { out.symbol = e.name; }' + + 'try { process.env[Symbol("s")] = "v"; out.symbolSet = null; }' + + 'catch (e) { out.symbolSet = e.name; }' + + 'try { Object.defineProperty(process.env, "BUN_TEST_SHARE_NUM", { value: 7, writable: true, enumerable: true, configurable: true }); out.numeric = typeof process.env.BUN_TEST_SHARE_NUM; }' + + 'catch (e) { out.numeric = e.code; }' + + 'try { Object.freeze(process.env); out.freeze = null; } catch (e) { out.freeze = e.name; }' + + 'out.extensibleAfterFreeze = Object.isExtensible(process.env);' + + 'require("worker_threads").parentPort.postMessage(out);', + { eval: true, env: SHARE_ENV }, + ).on("message", out => { console.log(JSON.stringify(out)); process.exit(0); });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout.trim()), stderr, exitCode }).toEqual({ + out: { + partial: "ERR_INVALID_OBJECT_DEFINE_PROPERTY", + symbol: "TypeError", + symbolSet: "TypeError", + numeric: "string", + freeze: "TypeError", + extensibleAfterFreeze: true, + }, + stderr: "", + exitCode: 0, + }); + }); + + it("a bare optional-value flag does not swallow the next flag", async () => { + using dir = tempDir("worker-execargv-optval", { + "preload-o.js": "globalThis.__o = 'O';", + "main.js": `console.log(JSON.stringify(process.execArgv)); + new (require("worker_threads").Worker)( + "require('worker_threads').parentPort.postMessage(globalThis.__o)", + { eval: true, execArgv: process.execArgv }, + ).on("message", t => { console.log(t); process.exit(0); });`, + }); + const p = join(String(dir), "preload-o.js"); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--config", "-r", p, "main.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ lines: stdout.trim().split(/\r?\n/), stderr, exitCode }).toEqual({ + lines: [JSON.stringify(["--config", "-r", p]), "O"], + stderr: "", + exitCode: 0, + }); + }); + + it("bun's own glued short flags round-trip through process.execArgv", async () => { + using dir = tempDir("worker-execargv-roundtrip", { + "preload-rt.js": "globalThis.__rt = 'R';", + "main.js": `console.log(JSON.stringify(process.execArgv)); + new (require("worker_threads").Worker)( + "require('worker_threads').parentPort.postMessage(globalThis.__rt)", + { eval: true, execArgv: process.execArgv }, + ).on("message", t => { console.log(t); process.exit(0); });`, + }); + const p = join(String(dir), "preload-rt.js"); + const cases: [string[], string[]][] = [ + [[`-r${p}`], ["-r", p]], + [[`-r=${p}`], ["-r", p]], + [[`-br${p}`], ["-b", "-r", p]], + [ + ["-br", p], + ["-b", "-r", p], + ], + [ + ["run", `-r${p}`], + ["-r", p], + ], + ]; + for (const [launch, normalized] of cases) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...launch, "main.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ launch, lines: stdout.trim().split(/\r?\n/), stderr, exitCode }).toEqual({ + launch, + lines: [JSON.stringify(normalized), "R"], + stderr: "", + exitCode: 0, + }); + } + }); + + it("rejects a glued short-flag value like node", async () => { + using dir = tempDir("worker-execargv-glued", { "preload-g.js": "globalThis.__glued = 'G';" }); + const p = join(String(dir), "preload-g.js"); + for (const form of [`-r${p}`, `-r=${p}`]) { + let err: any; + try { + new Worker("1", { eval: true, execArgv: [form] }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe(`Initiated Worker with invalid execArgv flags: ${form}`); + } + const w = new Worker("require('worker_threads').parentPort.postMessage(globalThis.__glued);", { + eval: true, + execArgv: ["-r", p], + }); + const [got] = await once(w, "message"); + expect(got).toBe("G"); + }); + + it("accepts node's whole-token short aliases", async () => { + const w = new Worker("require('worker_threads').parentPort.postMessage(process.execArgv);", { + eval: true, + execArgv: ["-pe", "1"], + }); + const [got] = await once(w, "message"); + expect(got).toEqual(["-pe", "1"]); + }); + + it("rejects glued short flags in NODE_OPTIONS like node", async () => { + for (const form of ["-r./nope.js", "-r=./nope.js", "-e1+1"]) { + let err: any; + try { + new Worker("1", { eval: true, env: { NODE_OPTIONS: form } }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe( + `Initiated Worker with invalid NODE_OPTIONS env variable: ${form} is not allowed in NODE_OPTIONS`, + ); + } + using dir = tempDir("worker-nodeopts-glued", { "preload-n.js": "globalThis.__nopts = 'N';" }); + const p = join(String(dir), "preload-n.js").replaceAll("\\", "/"); + const w = new Worker("1", { eval: true, env: { ...process.env, NODE_OPTIONS: `-r ${p}` } }); + await once(w, "exit"); + }); + + it("rejects chained or glued boolean short flags like node", () => { + for (const bad of ["-br./nope.js", "-bz", "-b=x"]) { + let err: any; + try { + new Worker("1", { eval: true, execArgv: [bad] }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_INVALID_EXEC_ARGV"); + expect(err?.message).toBe(`Initiated Worker with invalid execArgv flags: ${bad}`); + } + }); + + it("--require in execArgv runs before the worker entry", async () => { + using dir = tempDir("worker-execargv-require", { "preload-a.js": "console.log('A');" }); + const w = new Worker("console.log('B');", { + eval: true, + execArgv: ["--require", join(String(dir), "preload-a.js")], + stdout: true, + }); + let out = ""; + w.stdout.on("data", c => (out += c)); + const ended = once(w.stdout, "end"); // attach before exit so a fast end is not missed + await once(w, "exit"); + await ended; + expect(out).toBe("A\nB\n"); + }); + + it("--expose-gc in execArgv exposes gc() in that worker only", async () => { + const w = new Worker("require('worker_threads').parentPort.postMessage(typeof globalThis.gc);", { + eval: true, + execArgv: ["--expose-gc"], + }); + const [type] = await once(w, "message"); + expect(type).toBe("function"); + // A sibling worker with a fresh execArgv does not get gc(). + const w2 = new Worker("require('worker_threads').parentPort.postMessage(typeof globalThis.gc);", { + eval: true, + execArgv: [], + }); + const [type2] = await once(w2, "message"); + expect(type2).toBe("undefined"); + }); + + it("inheriting workers take --expose-gc from the process", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--expose-gc", + "-e", + "new (require('worker_threads').Worker)(\"require('worker_threads').parentPort.postMessage(typeof globalThis.gc)\", { eval: true }).on('message', t => { console.log(t); process.exit(0); })", + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "function", stderr: "", exitCode: 0 }); + }); + + it("inheriting workers take --expose-gc behind a value-taking Bun flag", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--cwd", + process.cwd(), + "--expose-gc", + "-e", + "new (require('worker_threads').Worker)(\"require('worker_threads').parentPort.postMessage(typeof globalThis.gc)\", { eval: true }).on('message', t => { console.log(t); process.exit(0); })", + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "function", stderr: "", exitCode: 0 }); + }); + + it("inheriting workers take --expose-gc behind a chained short whose value is the next token", async () => { + using dir = tempDir("worker-inherit-chained-short", { "noop.js": "" }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-br", + join(String(dir), "noop.js"), + "--expose-gc", + "-e", + "new (require('worker_threads').Worker)(\"require('worker_threads').parentPort.postMessage(typeof globalThis.gc)\", { eval: true }).on('message', t => { console.log(t); process.exit(0); })", + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "function", stderr: "", exitCode: 0 }); + }); }); test("eval does not leak source code", async () => {