diff --git a/Execution/Interpreter.cs b/Execution/Interpreter.cs index d388c5b..ca157b3 100644 --- a/Execution/Interpreter.cs +++ b/Execution/Interpreter.cs @@ -10,6 +10,7 @@ using SharpTS.Runtime.Types; using SharpTS.TypeSystem; using System.Collections.Frozen; +using System.Threading; namespace SharpTS.Execution; @@ -130,9 +131,6 @@ public Interpreter() // Track all pending timers for cleanup on disposal private readonly System.Collections.Concurrent.ConcurrentBag _pendingTimers = new(); - // Event loop for managing async handles (servers, timers, etc.) - private readonly EventLoop _eventLoop = new(); - // Virtual timer system - timers are checked and executed on the main thread during loop iterations. // This avoids thread scheduling issues on macOS where background threads may not get CPU time. // Uses PriorityQueue for O(log n) insert and O(log n) extraction of due timers. @@ -141,6 +139,15 @@ public Interpreter() // Volatile flag for O(1) "queue empty" check without acquiring lock private volatile bool _hasScheduledTimers; + // Active handles counter - keeps the event loop alive while there are active operations + private int _activeHandles; + private readonly object _activeHandlesLock = new(); + + // Event loop infrastructure - BlockingCollection for efficient waiting (no polling) + // SynchronizationContext routes async/await continuations back to the main thread + private readonly System.Collections.Concurrent.BlockingCollection _callbackQueue = new(); + private InterpreterSynchronizationContext? _eventLoopSyncContext; + /// /// Represents a scheduled timer callback that will be executed by the main thread. /// @@ -162,6 +169,36 @@ public VirtualTimer(long fireTimeMs, int intervalMs, Action callback, bool isInt } } + /// + /// Custom SynchronizationContext that routes async/await continuations back to the event loop. + /// Ensures all user callbacks execute on the main interpreter thread (Node.js semantics). + /// + private sealed class InterpreterSynchronizationContext : SynchronizationContext + { + private readonly Action _enqueue; + + public InterpreterSynchronizationContext(Action enqueue) + => _enqueue = enqueue; + + /// + /// Posts a callback to be executed asynchronously on the event loop thread. + /// Called by .NET when an async operation completes. + /// + public override void Post(SendOrPostCallback d, object? state) + => _enqueue(() => d(state)); + + /// + /// Sends a callback to be executed synchronously. Simplified to use Post. + /// + public override void Send(SendOrPostCallback d, object? state) + => Post(d, state); + + /// + /// Creates a copy of this SynchronizationContext. + /// + public override SynchronizationContext CreateCopy() => this; + } + /// /// Gets whether this interpreter has been disposed. /// Timer callbacks check this before executing to prevent race conditions. @@ -196,53 +233,231 @@ internal VirtualTimer ScheduleTimer(int delayMs, int intervalMs, Action callback _virtualTimerQueue.Enqueue(timer, fireTime); _hasScheduledTimers = true; } + // Wake the event loop if the timer fires soon (within 10ms) + // This ensures immediate timers (setTimeout(fn, 0)) are processed promptly + if (delayMs <= 10) + { + WakeEventLoop(); + } return timer; } + /// + /// Wakes the event loop by enqueueing a no-op action. + /// Used when a timer or other operation needs prompt processing. + /// + private void WakeEventLoop() + { + if (!_isDisposed && !_callbackQueue.IsAddingCompleted) + { + try { _callbackQueue.Add(() => { }); } + catch (InvalidOperationException) { /* queue completed */ } + } + } + + /// + /// Enqueues a callback to be executed on the main event loop thread. + /// Thread-safe - can be called from any thread (HTTP accept loop, async I/O, etc). + /// + /// The callback action to execute on the main thread. + internal void EnqueueCallback(Action action) + { + if (!_isDisposed && !_callbackQueue.IsAddingCompleted) + { + try { _callbackQueue.Add(action); } + catch (InvalidOperationException) { /* queue completed */ } + } + } + + /// + /// Calculates the timeout until the next timer fires. + /// Used by the event loop to efficiently wait without polling. + /// + /// TimeSpan until next timer, or 60 seconds if no timers pending. + private TimeSpan GetNextTimerTimeout() + { + lock (_virtualTimersLock) + { + // Remove cancelled timers at the front of the queue + while (_virtualTimerQueue.TryPeek(out var timer, out _)) + { + if (!timer.IsCancelled) break; + _virtualTimerQueue.Dequeue(); + } + + if (!_virtualTimerQueue.TryPeek(out _, out var fireTime)) + { + _hasScheduledTimers = false; + return TimeSpan.FromSeconds(60); + } + + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var ms = fireTime - now; + + // Clamp to reasonable range: 0ms to 60 seconds + if (ms <= 0) return TimeSpan.Zero; + if (ms > 60000) return TimeSpan.FromSeconds(60); + return TimeSpan.FromMilliseconds(ms); + } + } + + /// + /// Increments the active handles count. Used by servers, timers, etc. to keep the event loop alive. + /// + internal void Ref() + { + lock (_activeHandlesLock) + { + _activeHandles++; + } + } + + /// + /// Decrements the active handles count. When count reaches zero, the event loop can exit. + /// + internal void Unref() + { + bool shouldWake = false; + lock (_activeHandlesLock) + { + if (_activeHandles > 0) + { + _activeHandles--; + shouldWake = _activeHandles == 0; + } + } + + if (shouldWake) + { + WakeEventLoop(); + } + } + /// /// Registers an async handle with the interpreter's event loop. - /// The interpreter will keep running while this handle is active. + /// Compatibility shim for existing handle-based callers. /// internal void RegisterHandle(IAsyncHandle handle) { - _eventLoop.Register(handle); + Ref(); } /// /// Unregisters an async handle from the interpreter's event loop. + /// Compatibility shim for existing handle-based callers. /// internal void UnregisterHandle(IAsyncHandle handle) { - _eventLoop.Unregister(handle); + Unref(); + } + + /// + /// Gets whether there are active handles keeping the event loop alive. + /// + internal bool HasActiveHandles + { + get + { + lock (_activeHandlesLock) + { + return _activeHandles > 0; + } + } } /// - /// Runs the event loop, processing timers and keeping the process alive while there are active handles. - /// Uses efficient waiting via ManualResetEventSlim instead of polling. + /// Runs the event loop, processing callbacks until there are no more active handles. + /// This is the main loop that keeps the program alive for servers, timers, etc. /// - private void RunEventLoop() + /// + /// Uses a BlockingCollection for efficient waiting (no CPU polling). + /// Sets up a SynchronizationContext to route async/await continuations back to this thread. + /// This provides Node.js-compatible single-threaded semantics where all user callbacks + /// execute on the main thread, while I/O operations run on the ThreadPool. + /// + public void RunEventLoop() { - // Check if there are scheduled timers - they also count as active handles - bool HasTimersOrHandles() => _hasScheduledTimers || _eventLoop.HasActiveHandles(); + // Set up SynchronizationContext so async/await continuations come back to this thread + _eventLoopSyncContext = new InterpreterSynchronizationContext(EnqueueCallback); + var previousSyncContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(_eventLoopSyncContext); - while (!_isDisposed && HasTimersOrHandles()) + try { - // Process any pending timer callbacks - ProcessPendingCallbacks(); + while (!_isDisposed) + { + // Exit immediately if there's no work keeping the loop alive + if (!HasActiveHandles && _callbackQueue.Count == 0) + { + break; + } + + // Calculate timeout until next timer fires + var timeout = GetNextTimerTimeout(); + + // Efficient wait: blocks until callback arrives OR timeout expires + // This uses no CPU while waiting (unlike Thread.Sleep polling) + if (_callbackQueue.TryTake(out var action, timeout)) + { + // Execute the queued callback (HTTP request handler, async continuation, etc.) + try + { + action(); + } + catch (Exception ex) + { + // Log uncaught exceptions but don't crash the event loop + Console.Error.WriteLine($"Uncaught exception in event loop callback: {ex.Message}"); + } + } + + // Process any due timers (setTimeout, setInterval callbacks) + ProcessPendingCallbacks(); + + // Exit condition: no active handles AND queue is empty + // This ensures all queued callbacks are processed before exiting (like Node.js) + if (!HasActiveHandles && _callbackQueue.Count == 0) + { + break; + } + } + } + finally + { + // Drain any remaining callbacks before fully exiting + // This handles edge cases where callbacks were queued during shutdown + DrainCallbackQueue(); - // If only timers are active (no server handles), we need to continue the loop - // If there are active handles, the event loop will wait efficiently - if (_eventLoop.HasActiveHandles()) + // Restore previous SynchronizationContext + SynchronizationContext.SetSynchronizationContext(previousSyncContext); + + // Complete the queue so any pending Add() calls don't block + try { _callbackQueue.CompleteAdding(); } + catch (ObjectDisposedException) { /* already disposed */ } + } + } + + /// + /// Drains any remaining callbacks from the queue during shutdown. + /// Ensures all queued work completes before the event loop fully exits. + /// + private void DrainCallbackQueue() + { + // Process any remaining callbacks synchronously + while (_callbackQueue.TryTake(out var action, TimeSpan.Zero)) + { + try { - // Let the event loop wait for state changes (with timeout for timer processing) - _eventLoop.Run(ProcessPendingCallbacks); + action(); } - else if (_hasScheduledTimers) + catch (Exception ex) { - // Only timers active - sleep briefly then check again - Thread.Sleep(10); + Console.Error.WriteLine($"Uncaught exception during event loop drain: {ex.Message}"); } } + + // Final timer processing + ProcessPendingCallbacks(); } /// @@ -320,8 +535,9 @@ public void Dispose() { _isDisposed = true; - // Dispose the event loop first to stop any waiting - _eventLoop.Dispose(); + // Complete the callback queue to unblock any waiting TryTake + try { _callbackQueue.CompleteAdding(); } + catch (ObjectDisposedException) { /* already disposed */ } // Cancel all pending timers to release resources immediately while (_pendingTimers.TryTake(out var timer)) @@ -336,6 +552,10 @@ public void Dispose() _hasScheduledTimers = false; } + // Dispose the callback queue + try { _callbackQueue.Dispose(); } + catch (ObjectDisposedException) { /* already disposed */ } + GC.SuppressFinalize(this); } @@ -753,7 +973,7 @@ private ExecutionResult ExecuteExport(Stmt.Export export) { var result = Execute(export.Declaration); if (result.IsAbrupt) return result; - + if (_currentModuleInstance != null) { _currentModuleInstance.DefaultExport = GetDeclaredValue(export.Declaration); @@ -823,7 +1043,7 @@ private ExecutionResult ExecuteExport(Stmt.Export export) } } } - + return ExecutionResult.Success(); } diff --git a/Runtime/BuiltIns/TimerBuiltIns.cs b/Runtime/BuiltIns/TimerBuiltIns.cs index 8b96221..a3a3888 100644 --- a/Runtime/BuiltIns/TimerBuiltIns.cs +++ b/Runtime/BuiltIns/TimerBuiltIns.cs @@ -24,6 +24,7 @@ public static SharpTSTimeout SetTimeout(Interpreter interpreter, ISharpTSCallabl { var cts = new CancellationTokenSource(); var timeout = new SharpTSTimeout(cts); + timeout.AttachRefTracking(interpreter.Ref, interpreter.Unref); // Ensure delay is non-negative int delay = Math.Max(0, (int)delayMs); @@ -33,7 +34,18 @@ public static SharpTSTimeout SetTimeout(Interpreter interpreter, ISharpTSCallabl { if (!cts.IsCancellationRequested && !interpreter.IsDisposed) { - callback.Call(interpreter, args); + try + { + callback.Call(interpreter, args); + } + finally + { + timeout.ReleaseRef(); + } + } + else + { + timeout.ReleaseRef(); } }, isInterval: false); @@ -74,6 +86,7 @@ public static SharpTSTimeout SetInterval(Interpreter interpreter, ISharpTSCallab { var cts = new CancellationTokenSource(); var interval = new SharpTSTimeout(cts); + interval.AttachRefTracking(interpreter.Ref, interpreter.Unref); int delay = Math.Max(0, (int)delayMs); // Schedule a virtual interval timer that will be checked and executed on the main thread diff --git a/Runtime/Types/SharpTSTimeout.cs b/Runtime/Types/SharpTSTimeout.cs index f6c2062..676c312 100644 --- a/Runtime/Types/SharpTSTimeout.cs +++ b/Runtime/Types/SharpTSTimeout.cs @@ -19,6 +19,9 @@ public class SharpTSTimeout private Task? _task; private Timer? _timer; private bool _hasRef = true; + private bool _isRefed; + private Action? _onRef; + private Action? _onUnref; /// /// Creates a new timeout handle with a unique ID and cancellation support. @@ -94,6 +97,8 @@ public void Cancel() // Dispose the timer to stop it from firing _timer?.Dispose(); _timer = null; + + ReleaseRef(); } /// @@ -103,7 +108,15 @@ public void Cancel() /// This timeout object for method chaining. public SharpTSTimeout Ref() { - _hasRef = true; + if (!_hasRef) + { + _hasRef = true; + if (!_isRefed) + { + _onRef?.Invoke(); + _isRefed = true; + } + } return this; } @@ -115,10 +128,43 @@ public SharpTSTimeout Ref() /// This timeout object for method chaining. public SharpTSTimeout Unref() { - _hasRef = false; + if (_hasRef) + { + _hasRef = false; + ReleaseRef(); + } return this; } + /// + /// Attaches ref/unref tracking callbacks for event loop liveness. + /// + /// Called when this timeout should keep the loop alive. + /// Called when this timeout should release the loop. + internal void AttachRefTracking(Action onRef, Action onUnref) + { + _onRef = onRef; + _onUnref = onUnref; + + if (_hasRef && !_isRefed) + { + _onRef?.Invoke(); + _isRefed = true; + } + } + + /// + /// Releases the active ref if one is held. Safe to call multiple times. + /// + internal void ReleaseRef() + { + if (_isRefed) + { + _isRefed = false; + _onUnref?.Invoke(); + } + } + public override bool Equals(object? obj) => obj is SharpTSTimeout other && _id == other._id; diff --git a/SharpTS.Tests/SharedTests/TimerTests.cs b/SharpTS.Tests/SharedTests/TimerTests.cs index 1380616..06fb7c7 100644 --- a/SharpTS.Tests/SharedTests/TimerTests.cs +++ b/SharpTS.Tests/SharedTests/TimerTests.cs @@ -9,9 +9,8 @@ namespace SharpTS.Tests.SharedTests; /// /// /// -/// Important: Timers do NOT keep the process alive. -/// Unlike Node.js where timers with .ref() (the default) keep the event loop running, -/// SharpTS compiled programs exit when Main() returns, regardless of pending timers. +/// Important: In interpreted mode, timers keep the event loop alive by default. +/// In compiled mode, programs exit when Main() returns, regardless of pending timers. /// /// /// Note: All tests now run in both interpreted and compiled modes. @@ -104,6 +103,30 @@ public void SetTimeout_DefaultDelay_ExecutesCallback_Compiled(ExecutionMode mode Assert.Contains("done", output); } + [Theory] + [MemberData(nameof(ExecutionModes.InterpretedOnly), MemberType = typeof(ExecutionModes))] + public void SetTimeout_KeepsEventLoopAlive_Interpreted(ExecutionMode mode) + { + // In interpreted mode, the event loop should stay alive for timers by default + var source = @" + setTimeout(() => { console.log('executed'); }, 10); + "; + var output = TestHarness.Run(source, mode); + Assert.Equal("executed\n", output); + } + + [Theory] + [MemberData(nameof(ExecutionModes.InterpretedOnly), MemberType = typeof(ExecutionModes))] + public void SetTimeout_Unref_AllowsExit_Interpreted(ExecutionMode mode) + { + // unref() should allow the program to exit without running the timer + var source = @" + setTimeout(() => { console.log('should not run'); }, 10).unref(); + "; + var output = TestHarness.Run(source, mode); + Assert.Equal(string.Empty, output); + } + #endregion #region clearTimeout Tests