diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index f45f81c..6859c7e 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -389,6 +389,95 @@ public void AnUnboundedRunKeepsInlineTaskExecution() Assert.Equal("1", result.Value); } + // Under a budget, a wait without bound still runs a queued task inline when its scheduler lets the waiting thread + // (as the framework's wait does): a task queued to the host's main-thread synchronization context, waited on from + // that thread, would otherwise wait until the budget (Godot: GodotSynchronizationContext). + [Theory] + [InlineData("return Task.Factory.StartNew(() => 13, System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).Result;")] + [InlineData("return Task.Factory.StartNew(() => 13, System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).GetAwaiter().GetResult();")] + [InlineData("var t = Task.Factory.StartNew(() => 13, System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());\nt.Wait();\nreturn t.Result;")] + [InlineData("var t = Task.Factory.StartNew(() => 13, System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());\nTask.WaitAll(t);\nreturn t.Result;")] + [InlineData("var t = new Task(() => 13);\nt.Start(TaskScheduler.FromCurrentSynchronizationContext());\nreturn t.Result;")] + [InlineData("var p = Task.Run(() => 6);\nvar t = Task.Factory.StartNew(() => 7, System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());\nTask.WaitAll(p, t);\nreturn p.Result + t.Result;")] + public void ATaskQueuedToTheWaitingThreadsContextRunsInline(string code) + { + var (result, ms) = OnSingleThreadedContext(code, timeoutMs: 5000); + Assert.True(result.Success, result.ErrorKind + ": " + result.Error); + Assert.Equal("13", result.Value); + } + + // An inlined body that throws keeps the framework's exceptions: GetResult the original one, never a Timeout. + [Fact] + public void AnInlinedTaskThatThrowsKeepsItsException() + { + var (result, _) = OnSingleThreadedContext("return Task.Factory.StartNew(() => throw new InvalidOperationException(\"boom\"), System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).GetAwaiter().GetResult();", timeoutMs: 5000); + Assert.False(result.Success); + Assert.NotEqual(ErrorKind.Timeout, result.ErrorKind); + Assert.StartsWith("System.InvalidOperationException: boom", result.Error); + } + + // What no thread may run inline stays a sliced wait that stops at the budget instead of hanging the context's thread: + // an async method's continuation queued to the blocked context, or the context's task waited on by the snippet's work. + [Theory] + [InlineData("async Task F() { await Task.Delay(20); return 5; }\nreturn F().Result;")] + [InlineData("async Task F() { await Task.Delay(20); return 5; }\nreturn F().GetAwaiter().GetResult();")] + [InlineData("async Task F() { await Task.Delay(20); }\nF().Wait();")] + [InlineData("async Task F() { await Task.Delay(20); }\nTask.WaitAll(F());")] + [InlineData("var s = TaskScheduler.FromCurrentSynchronizationContext();\nreturn Task.Run(() => Task.Factory.StartNew(() => 13, System.Threading.CancellationToken.None, TaskCreationOptions.None, s).Result).Result;")] + // Run inline, its endless body still stops at the budget. + [InlineData("return Task.Factory.StartNew(() => { while (true) { } return 1; }, System.Threading.CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).Result;")] + public void AWaitNoThreadCanEndOnTheContextStopsAtTheBudget(string code) + { + var (result, ms) = OnSingleThreadedContext(code, timeoutMs: 1000); + Assert.True(result.ErrorKind == ErrorKind.Timeout, result.ErrorKind + ": " + result.Error); + Assert.Contains("The snippet ran longer than its time budget of 1000 ms (timeoutMs)", result.Error); + Assert.True(ms < 5000, $"stopped after {ms} ms"); // the compile included: a cold one on CI is slow + } + + /// Runs the snippet on a thread whose synchronization context queues work to that same thread (a host's main thread). + static (ExecuteResult Result, long Ms) OnSingleThreadedContext(string code, int timeoutMs) + { + ExecuteResult? result = null; + Exception? error = null; + var watch = new Stopwatch(); + var thread = new System.Threading.Thread(() => + { + var context = new SingleThreadedContext(); + System.Threading.SynchronizationContext.SetSynchronizationContext(context); + try + { + var runner = Fixtures.Runner(); + watch.Start(); + result = runner.Execute(new ExecuteRequest { Code = code, TimeoutMs = timeoutMs }); + watch.Stop(); + context.Drain(); // what the run queued to the context runs once the thread is free again + } + catch (Exception e) { error = e; } + }) { IsBackground = true }; + thread.Start(); + Assert.True(thread.Join(60_000), "the context's thread hung"); + Assert.Null(error); + return (result!, watch.ElapsedMilliseconds); + } + + /// A host's main-thread context: posted work waits in a queue until that thread is free to run it. + sealed class SingleThreadedContext : System.Threading.SynchronizationContext + { + readonly System.Collections.Concurrent.ConcurrentQueue<(System.Threading.SendOrPostCallback, object?)> _queue = new(); + + public override void Post(System.Threading.SendOrPostCallback d, object? state) => _queue.Enqueue((d, state)); + + public override void Send(System.Threading.SendOrPostCallback d, object? state) => throw new NotSupportedException(); + + public override System.Threading.SynchronizationContext CreateCopy() => this; + + public void Drain() + { + while (_queue.TryDequeue(out var item)) + item.Item1(item.Item2); + } + } + // A wait the guards cannot slice (here a named argument) that returns past the budget: the run is no success. [Fact] public void ARunThatOutranItsBudgetInAnUnslicedCallIsATimeout() diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index c4a178a..37cbc9b 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -310,7 +310,12 @@ static int Ms(global::System.TimeSpan timeout) public static bool TaskWait(global::System.Threading.Tasks.Task task, int millisecondsTimeout) => TaskWait(task, millisecondsTimeout, default); public static bool TaskWait(global::System.Threading.Tasks.Task task, global::System.TimeSpan timeout) => TaskWait(task, Ms(timeout), default); public static void TaskWait(global::System.Threading.Tasks.Task task, global::System.Threading.CancellationToken cancellationToken) => TaskWait(task, -1, cancellationToken); - public static bool TaskWait(global::System.Threading.Tasks.Task task, int millisecondsTimeout, global::System.Threading.CancellationToken cancellationToken) => Sliced(millisecondsTimeout, ms => task.Wait(ms, cancellationToken)); + public static bool TaskWait(global::System.Threading.Tasks.Task task, int millisecondsTimeout, global::System.Threading.CancellationToken cancellationToken) + { + if (millisecondsTimeout == -1 && !cancellationToken.CanBeCanceled) + Inline(task); + return Sliced(millisecondsTimeout, ms => task.Wait(ms, cancellationToken)); + } public static void TaskWaitAll(params global::System.Threading.Tasks.Task[] tasks) => TaskWaitAll(tasks, -1, default); public static void TaskWaitAll(global::System.Collections.Generic.IEnumerable tasks, global::System.Threading.CancellationToken cancellationToken = default) => @@ -318,7 +323,13 @@ public static void TaskWaitAll(global::System.Collections.Generic.IEnumerable TaskWaitAll(tasks, millisecondsTimeout, default); public static bool TaskWaitAll(global::System.Threading.Tasks.Task[] tasks, global::System.TimeSpan timeout) => TaskWaitAll(tasks, Ms(timeout), default); public static void TaskWaitAll(global::System.Threading.Tasks.Task[] tasks, global::System.Threading.CancellationToken cancellationToken) => TaskWaitAll(tasks, -1, cancellationToken); - public static bool TaskWaitAll(global::System.Threading.Tasks.Task[] tasks, int millisecondsTimeout, global::System.Threading.CancellationToken cancellationToken) => Sliced(millisecondsTimeout, ms => global::System.Threading.Tasks.Task.WaitAll(tasks, ms, cancellationToken)); + public static bool TaskWaitAll(global::System.Threading.Tasks.Task[] tasks, int millisecondsTimeout, global::System.Threading.CancellationToken cancellationToken) + { + if (millisecondsTimeout == -1 && !cancellationToken.CanBeCanceled && tasks != null) + for (var i = tasks.Length - 1; i >= 0; i--) // the framework's order + Inline(tasks[i]); + return Sliced(millisecondsTimeout, ms => global::System.Threading.Tasks.Task.WaitAll(tasks, ms, cancellationToken)); + } public static int TaskWaitAny(params global::System.Threading.Tasks.Task[] tasks) => TaskWaitAny(tasks, -1, default); public static int TaskWaitAny(global::System.Threading.Tasks.Task[] tasks, int millisecondsTimeout) => TaskWaitAny(tasks, millisecondsTimeout, default); @@ -331,9 +342,40 @@ public static int TaskWaitAny(global::System.Threading.Tasks.Task[] tasks, int m return index; } + // A wait without bound or token first offers a task that is still queued to its scheduler to run inline on this thread, + // as the framework's own wait does (Task.WrappedTryRunInline): the scheduler decides whether this thread may run it (a + // synchronization-context task waited on from that context's thread may). A task it declines (the thread pool's from + // another thread) or one with no body to run (an async method's) is waited for in slices. Not on a thread-pool thread: + // there the pool's scheduler could hand it any task from the thread's local queue (not necessarily the snippet's), which + // other pool threads run anyway. Where the runtime lacks that method, every wait is sliced. + static readonly global::System.Reflection.MethodInfo _tryRunInline = FindTryRunInline(); + + static global::System.Reflection.MethodInfo FindTryRunInline() + { + try + { + return typeof(global::System.Threading.Tasks.Task).GetMethod(""WrappedTryRunInline"", + global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.NonPublic, null, global::System.Type.EmptyTypes, null); + } + catch (global::System.Exception) { return null; } // never a failing type initializer: that would break every guarded snippet + } + + static void Inline(global::System.Threading.Tasks.Task task) + { + if (task == null || task.Status != global::System.Threading.Tasks.TaskStatus.WaitingToRun || _tryRunInline == null + || global::System.Threading.Thread.CurrentThread.IsThreadPoolThread) + return; + try { _tryRunInline.Invoke(task, null); } + catch (global::System.Reflection.TargetInvocationException e) when (e.InnerException != null) + { + global::System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException).Throw(); // the scheduler's own failure, as the framework's wait throws it + } + } + /// The task once it has completed (.Result, .GetAwaiter().GetResult() then return at once). public static T Ready(T task) where T : global::System.Threading.Tasks.Task { + Inline(task); if (task != null) Sliced(-1, ms => { diff --git a/spec/contract.md b/spec/contract.md index 75dd5d7..7b2705d 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -158,9 +158,13 @@ the host turns them off (`CodeRunnerOptions.ExecutionGuards`): Each becomes the guard's stand-in of the same parameters; values, exceptions, timeouts and wake-ups stay the same, a wait that succeeded is never followed by a stop (it may have acquired a semaphore or a mutex), and a wait of at most 50 ms is the framework's own, as is a wait without bound where nothing could stop it (a run without budget, not work - of a run). One difference: under a budget, a wait without bound for a task does not run the task inline on the - waiting thread when it is still queued (a task only its scheduler's inline path can run, such as a - synchronization-context scheduler waited on from its own thread, then waits until the budget). Not covered (stopped + of a run). `Task.Wait` / `Task.WaitAll` without a timeout and without a cancelable token, `Result` and `GetResult` + first offer a task that is still queued to its scheduler to run inline on the waiting thread, as the framework's own + waits do (`WaitAny` never runs one inline): a task queued to a synchronization context and waited on from that + context's thread runs there and returns its value. A task the scheduler declines, one with no body to run (an async + method's, whose continuation waits for the blocked thread), a wait on a thread-pool thread, and every wait on a + runtime without the framework's inline path are waited for in slices and stop at the budget. A body run inline that + is not the snippet's own code is stopped only when it returns. Not covered (stopped only when the call returns): such a wait with named arguments, inside a conditional access (`a?.Task.Result`), as a property pattern path (`x is { Task.Result: 1 }`), through `base.`, a method group or a delegate; .NET 9+ `Task.WaitAll` given a span; `lock` / `Monitor.Enter` and other waits (`BlockingCollection.Take`, `Barrier`,