diff --git a/Compilation/RuntimeEmitter.MessageChannel.cs b/Compilation/RuntimeEmitter.MessageChannel.cs index a4d224a7..4556ba51 100644 --- a/Compilation/RuntimeEmitter.MessageChannel.cs +++ b/Compilation/RuntimeEmitter.MessageChannel.cs @@ -31,6 +31,7 @@ public partial class RuntimeEmitter private FieldBuilder _messagePortStartedField = null!; private FieldBuilder _messagePortClosedField = null!; private FieldBuilder _messagePortRefedField = null!; + private FieldBuilder _messagePortOnEnqueueField = null!; private ConstructorBuilder _messagePortCtor = null!; private MethodBuilder _messagePortDrain = null!; private MethodBuilder _messagePortStart = null!; @@ -59,6 +60,11 @@ private void EmitMessagePortClass(ModuleBuilder moduleBuilder, EmittedRuntime ru _messagePortStartedField = typeBuilder.DefineField("_started", _types.Boolean, FieldAttributes.Assembly); _messagePortClosedField = typeBuilder.DefineField("_closed", _types.Boolean, FieldAttributes.Assembly); _messagePortRefedField = typeBuilder.DefineField("_refed", _types.Boolean, FieldAttributes.Assembly); + // Optional on-enqueue notification. Null for ordinary in-process ports; set + // (reflectively) by CompiledMessagePortBridge when this port has been + // transferred to an interpreter worker, so a parent post wakes the worker loop + // to drain _pending event-driven instead of the worker polling (#465). + _messagePortOnEnqueueField = typeBuilder.DefineField("_onEnqueue", typeof(Action), FieldAttributes.Assembly); EmitMessagePortConstructorIl(typeBuilder, runtime); EmitMessagePortDrain(typeBuilder, runtime); @@ -286,6 +292,24 @@ private void EmitMessagePortPostMessage(TypeBuilder typeBuilder, EmittedRuntime il.Emit(OpCodes.Ldloc, clonedLocal); il.Emit(OpCodes.Callvirt, _types.ConcurrentQueueOfObject.GetMethod("Enqueue", [_types.Object])!); + // var cb = partner._onEnqueue; if (cb != null) cb(); + // Wakes a bridge-driven partner (an interpreter worker that adopted this port + // via CompiledMessagePortBridge) so it drains _pending event-driven rather than + // polling (#465). The volatile read pairs with the Thread.MemoryBarrier the + // bridge issues after installing the callback, so a cross-thread post on the + // parent loop reliably observes it. Null (skipped) for ordinary in-process ports. + var onEnqueueLocal = il.DeclareLocal(typeof(Action)); + var afterOnEnqueue = il.DefineLabel(); + il.Emit(OpCodes.Ldloc, partnerLocal); + il.Emit(OpCodes.Volatile); + il.Emit(OpCodes.Ldfld, _messagePortOnEnqueueField); + il.Emit(OpCodes.Stloc, onEnqueueLocal); + il.Emit(OpCodes.Ldloc, onEnqueueLocal); + il.Emit(OpCodes.Brfalse, afterOnEnqueue); + il.Emit(OpCodes.Ldloc, onEnqueueLocal); + il.Emit(OpCodes.Callvirt, typeof(Action).GetMethod("Invoke", Type.EmptyTypes)!); + il.MarkLabel(afterOnEnqueue); + // if (partner._started) $EventLoop.GetInstance().Schedule(new Action(partner.Drain)) il.Emit(OpCodes.Ldloc, partnerLocal); il.Emit(OpCodes.Ldfld, _messagePortStartedField); diff --git a/Execution/Interpreter.Properties.cs b/Execution/Interpreter.Properties.cs index 459c8579..fad1408a 100644 --- a/Execution/Interpreter.Properties.cs +++ b/Execution/Interpreter.Properties.cs @@ -143,7 +143,14 @@ public partial class Interpreter if (klass is ISharpTSCallable callable && klass is not SharpTSClass && klass is not BoundFunction) { List ctorArgs = await ctx.EvaluateAllAsync(newExpr.Arguments); - return callable.Call(this, ctorArgs); + try + { + return callable.Call(this, ctorArgs); + } + catch (Exception ex) when (IsNativeConstructorFailure(ex)) + { + throw new ThrowException(new SharpTSError(ex.Message)); + } } // Bound functions cannot be used as constructors (JS spec compliance) @@ -375,7 +382,14 @@ private RuntimeValue EvaluateNew(Expr.New newExpr) { ctorArgs.Add(Evaluate(arg)); } - return RuntimeValue.FromBoxed(callable.Call(this, ctorArgs)); + try + { + return RuntimeValue.FromBoxed(callable.Call(this, ctorArgs)); + } + catch (Exception ex) when (IsNativeConstructorFailure(ex)) + { + throw new ThrowException(new SharpTSError(ex.Message)); + } } // Bound functions cannot be used as constructors (JS spec compliance) @@ -406,6 +420,29 @@ private RuntimeValue EvaluateNew(Expr.New newExpr) return sharpClass.CallRV(this, arguments); } + /// + /// True when a host exception escaping a native built-in constructor (a + /// new on an that is not a user class) + /// should be re-surfaced to guest code as a real + /// instead of the bare message string the host-exception boundary would + /// otherwise bind to the catch variable (#464). + /// + /// + /// Native constructors such as Worker and MessageChannel validate + /// their input by throwing a plain (e.g. new Worker(…) + /// failing the workerData structured-clone). In interpreter mode a guest + /// try/catch previously bound that exception's message string, so + /// e.message was undefined and e instanceof Error was false; + /// compiled mode already surfaces a proper Error. Two kinds pass through + /// unwrapped: a (a guest throw already carrying its + /// value, e.g. routed out through a constructor that consumed a guest iterable) + /// and any (interpreter/ + /// runtime errors deliberately kept as message strings per the + /// backward-compat contract). + /// + private static bool IsNativeConstructorFailure(Exception ex) => + ex is not ThrowException && ex is not Diagnostics.Exceptions.SharpTSException; + /// /// Evaluates a this expression, returning the current instance. /// diff --git a/Execution/Interpreter.cs b/Execution/Interpreter.cs index 1ce7ace0..2df46c59 100644 --- a/Execution/Interpreter.cs +++ b/Execution/Interpreter.cs @@ -338,6 +338,22 @@ internal Runtime.Types.SharpTSObject GetRegExpPrototype() /// public string? EntryModulePath { get; set; } + /// + /// Worker-thread bindings for the worker_threads built-in module. Non-null + /// only on the isolated interpreter that runs a Worker's script. It makes + /// import { workerData, parentPort, threadId, isMainThread } from "worker_threads" + /// resolve to this worker's live values instead of the main-thread null + /// placeholders, so a worker can read its inputs via the canonical import form and + /// not only the bare worker-context globals (#410). + /// is the same port instance bound as the bare parentPort global, so a + /// message listener attached through the import receives the messages the + /// worker's message loop delivers to that instance. + /// + internal WorkerThreadsBindings? WorkerThreadsContext { get; set; } + + /// Live worker_threads values for the running Worker (see ). + internal sealed record WorkerThreadsBindings(object? WorkerData, object? ParentPort, double ThreadId); + // Flag to indicate interpreter has been disposed - timer callbacks should not execute private volatile bool _isDisposed; @@ -1447,6 +1463,17 @@ private void ExecuteModule(ParsedModule module) if (moduleName != null && BuiltInModuleValues.HasInterpreterSupport(moduleName)) { var exports = BuiltInModuleValues.GetModuleExports(moduleName); + // On a worker thread, rebind the worker_threads identity exports to this + // worker's live values so `import { workerData, parentPort } from + // "worker_threads"` sees the same inputs as the bare worker-context + // globals instead of the main-thread null placeholders (#410). + if (moduleName == "worker_threads" && WorkerThreadsContext is { } wtc) + { + exports["workerData"] = wtc.WorkerData; + exports["parentPort"] = wtc.ParentPort; + exports["threadId"] = wtc.ThreadId; + exports["isMainThread"] = false; + } foreach (var (name, value) in exports) { moduleInstance.SetExport(name, value); diff --git a/Runtime/BuiltIns/Modules/Interpreter/WorkerThreadsModuleInterpreter.cs b/Runtime/BuiltIns/Modules/Interpreter/WorkerThreadsModuleInterpreter.cs index ac2445a7..af1f4e46 100644 --- a/Runtime/BuiltIns/Modules/Interpreter/WorkerThreadsModuleInterpreter.cs +++ b/Runtime/BuiltIns/Modules/Interpreter/WorkerThreadsModuleInterpreter.cs @@ -42,12 +42,17 @@ public static class WorkerThreadsModuleInterpreter // BroadcastChannel constructor ["BroadcastChannel"] = new BroadcastChannelConstructor(), - // Synchronous message receive + // Synchronous message receive. Accepts a native MessagePort or a + // transferred compiled port adopted by this worker (#465). ["receiveMessageOnPort"] = BuiltInMethod.CreateV2("receiveMessageOnPort", 1, (interp, recv, args) => { - if (args.Length == 0 || args[0].ToObject() is not SharpTSMessagePort port) - throw new Exception("receiveMessageOnPort requires a MessagePort argument"); - return RuntimeValue.FromBoxed(port.ReceiveMessageSync()); + object? result = (args.Length == 0 ? null : args[0].ToObject()) switch + { + SharpTSMessagePort port => port.ReceiveMessageSync(), + CompiledMessagePortBridge bridge => bridge.ReceiveMessageSync(), + _ => throw new Exception("receiveMessageOnPort requires a MessagePort argument"), + }; + return RuntimeValue.FromBoxed(result); }), // SHARE_ENV constant (placeholder - we don't support env sharing) diff --git a/Runtime/Types/CompiledMessagePortBridge.cs b/Runtime/Types/CompiledMessagePortBridge.cs index ff0275be..d26a7632 100644 --- a/Runtime/Types/CompiledMessagePortBridge.cs +++ b/Runtime/Types/CompiledMessagePortBridge.cs @@ -27,34 +27,33 @@ namespace SharpTS.Runtime.Types; /// reusing its enqueue-to-partner + schedule-drain-on-$EventLoop logic so the /// parent's compiled listeners run on the parent loop thread. /// receive — the compiled partner's posts are always enqueued to the -/// transferred port's _pending queue (the drain is only scheduled when -/// the port has started, which this transferred port never does on the compiled -/// side). A recurring timer on the WORKER interpreter's loop drains that queue and -/// emits 'message' to the worker's listeners on the worker thread. The -/// interval timer also keeps the worker loop alive while the port is open, matching -/// Node's "a listening port is ref'd" semantics (#406, same liveness class as #329). +/// transferred port's _pending queue (the compiled Drain is only +/// scheduled when the port has started, which this transferred port never does on the +/// compiled side). The bridge instead installs an on-enqueue callback on the compiled +/// port (_onEnqueue): a parent post invokes it right after enqueuing, and it +/// marshals a drain onto the WORKER loop via the thread-safe EnqueueCallback, +/// which emits 'message' to the worker's listeners on the worker thread. This +/// is event-driven — an idle bridged port no longer wakes the worker loop on a timer +/// (#465). A keep-alive Ref on the worker loop holds it open while the port is +/// open, matching Node's "a listening port is ref'd" semantics (#406, same liveness +/// class as #329). /// /// /// All access to the emitted type is via reflection cached at adoption time, since /// this class lives in SharpTS.dll while the compiled port lives in the output -/// assembly. The field/method names mirrored here (PostMessage, _pending) -/// MUST stay in sync with RuntimeEmitter.MessageChannel.cs. +/// assembly. The field/method names mirrored here (PostMessage, _pending, +/// _onEnqueue) MUST stay in sync with RuntimeEmitter.MessageChannel.cs. /// public sealed class CompiledMessagePortBridge : SharpTSEventEmitter { - // The poll cadence for draining the compiled port's incoming queue onto the - // worker loop. Matches WorkerMessageHandler's 10ms parent→worker poll so the - // two worker-bound delivery paths feel identical. - private const int PollIntervalMs = 10; - private readonly object _compiledPort; // transferred emitted $MessagePort private readonly MethodInfo _postMessageMethod; // $MessagePort.PostMessage(object) private readonly ConcurrentQueue _incoming; // $MessagePort._pending + private readonly FieldInfo _onEnqueueField; // $MessagePort._onEnqueue (Action wake hook) // The worker interpreter that owns delivery. Captured the first time the worker // attaches a 'message' listener / starts the port; null until then. private Interp? _owner; - private Interp.VirtualTimer? _pollTimer; private bool _started; private bool _closed; private bool _loopRefed; @@ -63,11 +62,13 @@ public sealed class CompiledMessagePortBridge : SharpTSEventEmitter // returns Unknown for subclasses, routing member access through the per-type // registration in BuiltInRegistry, which reaches this class's GetMember. - private CompiledMessagePortBridge(object compiledPort, MethodInfo postMessageMethod, ConcurrentQueue incoming) + private CompiledMessagePortBridge(object compiledPort, MethodInfo postMessageMethod, + ConcurrentQueue incoming, FieldInfo onEnqueueField) { _compiledPort = compiledPort; _postMessageMethod = postMessageMethod; _incoming = incoming; + _onEnqueueField = onEnqueueField; } /// @@ -99,7 +100,11 @@ public static CompiledMessagePortBridge Adopt(object compiledPort) throw new StructuredClone.DataCloneError( "Cannot transfer $MessagePort: _pending is not a ConcurrentQueue"); - return new CompiledMessagePortBridge(compiledPort, postMessage, incoming); + var onEnqueueField = type.GetField("_onEnqueue", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new StructuredClone.DataCloneError( + "Cannot transfer $MessagePort: no _onEnqueue field (emitted shape changed?)"); + + return new CompiledMessagePortBridge(compiledPort, postMessage, incoming, onEnqueueField); } /// @@ -122,8 +127,9 @@ private void PostMessage(object? message) } /// - /// Begins delivery: schedules a recurring drain of the compiled port's incoming - /// queue onto the worker loop. Idempotent. No-op until an owner interpreter has + /// Begins delivery: installs an on-enqueue wake callback on the compiled port so a + /// parent post drains the incoming queue onto the worker loop event-driven, and + /// drains anything already queued. Idempotent. No-op until an owner interpreter has /// been captured (via the first on('message')/start()). /// private void Start() @@ -133,23 +139,55 @@ private void Start() _started = true; - // Keep the worker loop alive while the port is open. RunEventLoop's exit - // check counts active handles and queued callbacks but NOT scheduled timers, - // so the poll interval alone would not hold the loop open — without this Ref - // the worker can quiesce and exit before the parent's message is ever polled - // (Node: a listening port is ref'd; #406, same liveness class as #329). + // Keep the worker loop alive while the port is open. RunEventLoop's exit check + // counts active handles and queued callbacks but NOT a port that is merely + // waiting for a message, so without this Ref the worker could quiesce and exit + // before the parent ever posts (Node: a listening port is ref'd; #406, same + // liveness class as #329). if (!_loopRefed) { _loopRefed = true; _owner.Ref(); } - // The interval timer drains _incoming on the worker loop thread. Delay 0 so - // anything the parent already posted before the worker started is delivered - // on the next tick; the partner's posts land in _incoming asynchronously, so - // the loop must poll (the compiled enqueue cannot wake this interpreter). - // An event-driven alternative is tracked as a follow-up (#465). - _pollTimer = _owner.ScheduleTimer(0, PollIntervalMs, Pump, isInterval: true); + // Event-driven receive (#465): a parent post enqueues to _incoming on the + // parent loop thread and then invokes this callback, which marshals a drain + // onto the worker loop via the thread-safe EnqueueCallback (it wakes the + // worker's blocking wait). This replaces a 10ms poll, so an idle bridged port + // no longer wakes the worker loop 100×/second. The MemoryBarrier pairs with the + // volatile read the emitted PostMessage does, so the parent reliably observes + // the installed callback once set. + _onEnqueueField.SetValue(_compiledPort, (Action)OnPartnerEnqueued); + Thread.MemoryBarrier(); + + // Drain anything the parent posted before the callback was installed. + _owner.EnqueueCallback(Pump); + } + + /// + /// On-enqueue hook invoked by the compiled partner's PostMessage (on the + /// parent loop thread) right after it enqueues to _incoming. Marshals a + /// drain onto the worker loop; is thread-safe + /// and wakes the worker's event loop. + /// + private void OnPartnerEnqueued() => _owner?.EnqueueCallback(Pump); + + /// + /// Synchronously dequeues one message from the compiled partner's incoming queue, + /// for worker_threads.receiveMessageOnPort(port) on a transferred compiled + /// port (#465). Returns { message } (the payload is already an independent + /// structured clone) or null when the queue is empty or the port is closed, + /// matching . + /// + internal object? ReceiveMessageSync() + { + if (_closed || !_incoming.TryDequeue(out var message)) + return null; + + return new SharpTSObject(new Dictionary + { + ["message"] = message + }); } /// @@ -171,8 +209,9 @@ private void Pump() } /// - /// Stops worker-side delivery and cancels the poll timer so the worker loop can - /// quiesce and the worker can exit. Emits 'close' to the worker's listeners. + /// Stops worker-side delivery: clears the wake callback and releases the keep-alive + /// Ref so the worker loop can quiesce and the worker can exit. Emits 'close' to the + /// worker's listeners. /// private void Close() { @@ -181,11 +220,10 @@ private void Close() _closed = true; - if (_pollTimer != null) - { - _pollTimer.IsCancelled = true; - _pollTimer = null; - } + // Stop the parent from waking this (now closed) bridge. A post that already + // loaded the old callback is still harmless — Pump short-circuits on _closed. + _onEnqueueField.SetValue(_compiledPort, null); + Thread.MemoryBarrier(); // Release the keep-alive Ref so the worker loop can quiesce and the worker // thread can exit. diff --git a/Runtime/Types/SharpTSWorker.cs b/Runtime/Types/SharpTSWorker.cs index f6caddc7..43958d17 100644 --- a/Runtime/Types/SharpTSWorker.cs +++ b/Runtime/Types/SharpTSWorker.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using SharpTS.Diagnostics; using SharpTS.Execution; using SharpTS.Modules; using SharpTS.Parsing; @@ -298,7 +299,55 @@ private void RunWorkerScript() // Set up worker globals SetupWorkerGlobals(interpreter); - // Parse and execute the script + // Set up message handling loop + var messageHandler = new WorkerMessageHandler(this, interpreter); + messageHandler.Start(); + + try + { + // A worker whose script uses import/export must run through the same + // module pipeline the parent uses — the bare single-file path rejects any + // import at type-check ("Import statements require module mode"), which + // includes the canonical `import { workerData } from "worker_threads"` (#410). + if (UsesModuleSyntax(source, absolutePath)) + { + RunWorkerModule(interpreter, absolutePath); + } + else + { + RunWorkerSingleFile(interpreter, source); + } + } + finally + { + messageHandler.Stop(); + } + } + + /// + /// Decides whether the worker script must be run in module mode. Mirrors the + /// trigger the CLI uses for the parent (Program.RunFile): an + /// import/export statement, a triple-slash path reference, or a + /// CommonJS file using require/module.exports/exports.. + /// + private static bool UsesModuleSyntax(string source, string absolutePath) + { + var lexer = new Lexer(source); + lexer.ScanTokens(); + bool hasPathReferences = + lexer.TripleSlashDirectives.Any(d => d.Type == TripleSlashReferenceType.Path); + + bool isCjsFile = CommonJsDetector.Detect(absolutePath) == CommonJsDetector.ModuleKind.CommonJs + && (source.Contains("require(") || source.Contains("module.exports") || source.Contains("exports.")); + + return hasPathReferences || source.Contains("import ") || source.Contains("export ") || isCjsFile; + } + + /// + /// Runs a script-mode worker (no import/export) on a single-file pipeline. + /// + private static void RunWorkerSingleFile(Interpreter interpreter, string source) + { var lexer = new Lexer(source); var tokens = lexer.ScanTokens(); var parser = new Parser(tokens); @@ -311,23 +360,48 @@ private void RunWorkerScript() // Type check. AsWorkerContext lets the worker-scoped globals (parentPort, // postMessage, workerData, threadId, isMainThread) resolve instead of - // failing as undefined — they're bound below by SetupWorkerGlobals. + // failing as undefined — they're bound by SetupWorkerGlobals. var typeChecker = new TypeChecker().AsWorkerContext(); var typeMap = typeChecker.Check(parseResult.Statements); - // Set up message handling loop - var messageHandler = new WorkerMessageHandler(this, interpreter); - messageHandler.Start(); + interpreter.Interpret(parseResult.Statements, typeMap); + } - try + /// + /// Runs a module-mode worker through the same resolver/type-check/interpret + /// pipeline the parent uses, so import/export (including + /// import { workerData, parentPort } from "worker_threads") resolve. The + /// worker-context module bindings injected by + /// make the imported worker_threads identity exports carry this worker's live + /// values (#410). + /// + private static void RunWorkerModule(Interpreter interpreter, string absolutePath) + { + var resolver = new ModuleResolver(absolutePath); + var entryModule = resolver.LoadModule(absolutePath); + var allModules = resolver.GetModulesInOrder(entryModule); + + // AsWorkerContext keeps the bare worker-scoped globals resolving as `any` in + // every module, matching the single-file path. + var typeChecker = new TypeChecker().AsWorkerContext(); + var typeMap = typeChecker.CheckModules(allModules, resolver); + + var firstError = typeChecker.GetDiagnostics() + .FirstOrDefault(d => d.Severity == DiagnosticSeverity.Error); + if (firstError != null) { - // Execute the script - interpreter.Interpret(parseResult.Statements, typeMap); + throw new Exception($"Worker script type error: {firstError}"); } - finally + + // Variable resolution for O(1) lookups (built-in modules have no user statements). + var varResolver = new VariableResolver(interpreter); + foreach (var module in allModules) { - messageHandler.Stop(); + if (!module.IsBuiltIn) + varResolver.Resolve(module.Statements); } + + interpreter.InterpretModules(allModules, resolver, typeMap); } /// @@ -359,6 +433,14 @@ private void SetupWorkerGlobals(Interpreter interpreter) PostMessageToParent(args[0].ToObject(), transfer); return RuntimeValue.Null; })); + + // Mirror the same live values into the worker_threads module exports so a + // script that uses the canonical import form — `import { workerData, + // parentPort } from "worker_threads"` — sees this worker's inputs (#410). + // The very same parentPort instance is reused so a listener attached via the + // import receives the messages WorkerMessageHandler delivers to the bare global. + interpreter.WorkerThreadsContext = + new Interpreter.WorkerThreadsBindings(_workerData, parentPort, ThreadId); } /// @@ -822,9 +904,13 @@ public static SharpTSObject CreateModuleExports() ["MessagePort"] = null, // Can't construct directly ["receiveMessageOnPort"] = BuiltInMethod.CreateV2("receiveMessageOnPort", 1, static (_, _, args) => { - if (args.Length == 0 || args[0].ToObject() is not SharpTSMessagePort port) - throw new Exception("receiveMessageOnPort requires a MessagePort argument"); - return RuntimeValue.FromBoxed(ReceiveMessageOnPort(port)); + object? result = (args.Length == 0 ? null : args[0].ToObject()) switch + { + SharpTSMessagePort port => ReceiveMessageOnPort(port), + CompiledMessagePortBridge bridge => bridge.ReceiveMessageSync(), + _ => throw new Exception("receiveMessageOnPort requires a MessagePort argument"), + }; + return RuntimeValue.FromBoxed(result); }), }); } diff --git a/SharpTS.Tests/SharedTests/WorkerThreadsTests.cs b/SharpTS.Tests/SharedTests/WorkerThreadsTests.cs index 24e123e8..ced58159 100644 --- a/SharpTS.Tests/SharedTests/WorkerThreadsTests.cs +++ b/SharpTS.Tests/SharedTests/WorkerThreadsTests.cs @@ -789,10 +789,10 @@ public void Worker_TransferredMessagePort_StructuredClonesObjectPayloads(Executi /// cloned), in both modes — not silently shared and not an opaque crash. /// /// - /// Compiled mode surfaces the error message via e.message; interpreter mode - /// currently surfaces worker-construction failures as a raw string (a separate, - /// pre-existing quirk — see issue filed alongside #406), so the guest reads - /// whichever is present. Either way the rejection text is observable. + /// Both modes now surface the reason via e.message: interpreter mode wraps + /// the construction failure in a real Error (#464), and compiled mode yields + /// an object carrying message. The guest reads e.message with a string + /// fallback so the rejection text is observable either way. /// [Theory] [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] @@ -824,5 +824,219 @@ public void Worker_MessagePortInWorkerDataWithoutTransfer_IsRejected(ExecutionMo Assert.DoesNotContain("worker-should-not-start", output); } + /// + /// #465: a transferred port must round-trip repeatedly with the worker idle between + /// messages — exercising the event-driven receive (a parent post wakes the worker + /// loop to drain) rather than a one-shot. In compiled mode this drives + /// CompiledMessagePortBridge's on-enqueue wake; in interpreter mode the + /// cross-thread SharpTSMessagePort delivery. The parent sends the next ping + /// only after the previous reply, so each delivery happens while the worker is + /// otherwise quiescent. + /// + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Worker_TransferredMessagePort_MultipleRoundTripsWhileIdle(ExecutionMode mode) + { + var files = new Dictionary + { + ["worker_echo_port.ts"] = """ + const port: any = workerData.port; + let n = 0; + port.on("message", (m: any) => { + n++; + port.postMessage("pong" + n + ":" + m); + if (n >= 3) port.close(); + }); + """, + ["main.ts"] = """ + import { Worker, MessageChannel } from "worker_threads"; + const { port1, port2 } = new MessageChannel(); + const w = new Worker(__dirname + "/worker_echo_port.ts", { + workerData: { port: port1 }, + transferList: [port1], + }); + let replies = 0; + port2.on("message", (m: any) => { + console.log("recv:" + m); + replies++; + if (replies < 3) port2.postMessage("ping" + (replies + 1)); + else port2.close(); + }); + port2.postMessage("ping1"); + """ + }; + + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Contains("recv:pong1:ping1", output); + Assert.Contains("recv:pong2:ping2", output); + Assert.Contains("recv:pong3:ping3", output); + } + + /// + /// #465: worker_threads.receiveMessageOnPort(port) must work on a transferred + /// port that the worker drives with a synchronous poll (no 'message' + /// listener). The worker imports receiveMessageOnPort (module mode, #410) and + /// polls the port until a message arrives, then echoes it back. Exercises the + /// compiled CompiledMessagePortBridge.ReceiveMessageSync as well as the + /// interpreter SharpTSMessagePort path. + /// + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Worker_ReceiveMessageOnPort_OnTransferredPort(ExecutionMode mode) + { + var files = new Dictionary + { + ["worker_sync_port.ts"] = """ + import { workerData, receiveMessageOnPort } from "worker_threads"; + const port: any = workerData.port; + const timer = setInterval(() => { + const m: any = receiveMessageOnPort(port); + if (m) { + port.postMessage("sync-got:" + m.message); + clearInterval(timer); + port.close(); + } + }, 10); + """, + ["main.ts"] = """ + import { Worker, MessageChannel } from "worker_threads"; + const { port1, port2 } = new MessageChannel(); + const w = new Worker(__dirname + "/worker_sync_port.ts", { + workerData: { port: port1 }, + transferList: [port1], + }); + port2.on("message", (m: any) => { console.log("recv:" + m); port2.close(); }); + port2.postMessage("hello"); + """ + }; + + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Contains("recv:sync-got:hello", output); + } + + #endregion + + #region Worker scripts in module mode (#410) + + /// + /// Regression for #410: a worker script that uses the canonical Node import form + /// import { workerData, parentPort, ... } from "worker_threads" must run — + /// before the fix the worker ran on a bare single-file pipeline that rejected any + /// import at type-check ("Import statements require module mode"), and the failure + /// was swallowed by the worker's error event so the parent just produced no + /// output. The imported identity bindings must carry this worker's live values + /// (the running worker's workerData, a usable parentPort, + /// isMainThread === false, a positive threadId) rather than the + /// main-thread null placeholders. + /// + /// + /// The worker child script always runs under the interpreter, so this exercises + /// the same worker-side module pipeline regardless of the parent's mode. + /// __dirname routes the harness through the real-disk path so the worker can + /// load its script. Load-independent positive assertion (output present). + /// + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Worker_ImportFromWorkerThreads_ResolvesInModuleMode(ExecutionMode mode) + { + var files = new Dictionary + { + ["worker_import.ts"] = """ + import { workerData, parentPort, isMainThread, threadId } from "worker_threads"; + parentPort!.postMessage( + "wd=" + workerData + " main=" + isMainThread + " tid=" + (threadId > 0)); + """, + ["main.ts"] = """ + import { Worker } from "worker_threads"; + const w = new Worker(__dirname + "/worker_import.ts", { workerData: 123 }); + w.on("message", (e: any) => { console.log("received:" + e.data); }); + """ + }; + + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Contains("received:wd=123 main=false tid=true", output); + } + + /// + /// #410: a module-mode worker can also import its own sibling modules — the worker + /// runs through the full resolver/type-check/interpret pipeline, not just a special + /// case for worker_threads. Here the worker imports a relative helper and a + /// worker_threads binding together. + /// + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Worker_ImportRelativeModule_WorksInModuleMode(ExecutionMode mode) + { + var files = new Dictionary + { + ["greet.ts"] = """ + export function greet(name: any): string { return "hello " + name; } + """, + ["worker_rel.ts"] = """ + import { workerData, parentPort } from "worker_threads"; + import { greet } from "./greet"; + parentPort!.postMessage(greet(workerData)); + """, + ["main.ts"] = """ + import { Worker } from "worker_threads"; + const w = new Worker(__dirname + "/worker_rel.ts", { workerData: "alice" }); + w.on("message", (e: any) => { console.log("received:" + e.data); }); + """ + }; + + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Contains("received:hello alice", output); + } + + #endregion + + #region Worker construction failure surfaces as an Error (#464) + + /// + /// Regression for #464: when the Worker constructor fails (here an + /// uncloneable workerData containing a function), the value caught by guest + /// try/catch must be an object carrying the reason in .message — not + /// the bare message string the interpreter previously bound (typeof e was + /// "string", e.message undefined). Both modes now expose .message; + /// interpreter mode additionally surfaces a real Error (asserted below). + /// + /// + /// Compiled mode currently yields a plain object with message rather than a + /// real Error instance (a separate, general compiled-mode gap in how caught + /// host exceptions are wrapped — filed separately), so instanceof Error is + /// only asserted for the interpreter, which #464 targets. + /// + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Worker_UncloneableWorkerData_RejectsWithErrorObjectNotString(ExecutionMode mode) + { + var files = new Dictionary + { + ["worker_noop.ts"] = """ + console.log("worker-should-not-start"); + """, + ["main.ts"] = """ + import { Worker } from "worker_threads"; + try { + // A function is never structured-cloneable. + const w = new Worker(__dirname + "/worker_noop.ts", { workerData: { fn: () => 1 } }); + console.log("constructed-without-error"); + } catch (e: any) { + console.log("typeof=" + typeof e); + console.log("hasMessage=" + (e && typeof e.message === "string" && e.message.length > 0)); + console.log("isError=" + (e instanceof Error)); + } + """ + }; + + var output = TestHarness.RunModules(files, "main.ts", mode); + Assert.Contains("typeof=object", output); + Assert.Contains("hasMessage=true", output); + Assert.DoesNotContain("constructed-without-error", output); + Assert.DoesNotContain("worker-should-not-start", output); + if (mode == ExecutionMode.Interpreted) + Assert.Contains("isError=true", output); + } + #endregion }