From a58312f6ac874da0a1e7674880816bf625be3949 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 22:27:36 -0700 Subject: [PATCH 1/3] fix(csharp): a sliced task wait still runs a queued task inline where its scheduler allows Regression in affe6ef, measured live in Godot (main-thread GodotSynchronizationContext, 5 s budget): Task.Factory.StartNew(() => 13, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).Result returned 13 on 2556a2f and Timeout after the budget on affe6ef: under a budget, a wait without bound for a still-queued task no longer ran it inline on the waiting thread, so a task queued to the blocked thread's own context could never run. The guard's task waits (Wait / WaitAll / Result / GetAwaiter().GetResult()) without bound and without a cancelable token now first offer a still-queued task to its scheduler to run inline (Task.WrappedTryRunInline, the framework's own path, found by reflection; absent -> every wait is sliced). The scheduler decides whether this thread may run it; a declined task or one with no body (an async method's promise) is waited for in slices as before, so `async F().Result` on a main-thread context still stops at the budget. Tests on a single-threaded test SynchronizationContext: the regression case (Result, GetResult, Wait, WaitAll, Task.Start) returns 13 (fails without the fix: Timeout after 5 s); async-method waits, the context's task waited on from the pool, and an inlined endless body still stop at the budget. Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec.Tests/GuardTests.cs | 79 +++++++++++++++++++++++++++++ dotnet/CodeExec/ExecutionGuard.cs | 35 ++++++++++++- spec/contract.md | 8 +-- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index f45f81c..6535a47 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -389,6 +389,85 @@ 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;")] + 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); + Assert.True(ms < 2500, $"took {ms} ms"); + } + + // 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 < 2500, $"stopped after {ms} ms"); + } + + /// 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..a2f63c0 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) + foreach (var task in tasks) + Inline(task); + 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,29 @@ 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. Where the runtime lacks that + // method, every wait is sliced. + static readonly global::System.Reflection.MethodInfo _tryRunInline = typeof(global::System.Threading.Tasks.Task).GetMethod(""WrappedTryRunInline"", + global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.NonPublic, null, global::System.Type.EmptyTypes, null); + + static void Inline(global::System.Threading.Tasks.Task task) + { + if (task == null || task.Status != global::System.Threading.Tasks.TaskStatus.WaitingToRun || _tryRunInline == null || _tryRunInline.ReturnType != typeof(bool)) + 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..4baee2d 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -158,9 +158,11 @@ 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). A wait without bound and without a cancelable token for a task that is still queued first offers it to + its scheduler to run inline on the waiting thread, as the framework's wait does: 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, or one with no body to run (an async method's, whose continuation waits for the blocked thread), + is waited for in slices and stops at the budget. 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`, From dd0bb149be24ba1f3232b32c2697b833a82628f3 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 22:28:17 -0700 Subject: [PATCH 2/3] simplify: drop the unused return-type check of the inline lookup Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec/ExecutionGuard.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index a2f63c0..33738ca 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -352,7 +352,7 @@ public static int TaskWaitAny(global::System.Threading.Tasks.Task[] tasks, int m static void Inline(global::System.Threading.Tasks.Task task) { - if (task == null || task.Status != global::System.Threading.Tasks.TaskStatus.WaitingToRun || _tryRunInline == null || _tryRunInline.ReturnType != typeof(bool)) + if (task == null || task.Status != global::System.Threading.Tasks.TaskStatus.WaitingToRun || _tryRunInline == null) return; try { _tryRunInline.Invoke(task, null); } catch (global::System.Reflection.TargetInvocationException e) when (e.InnerException != null) From 2de8e17c9c83d0b593ca0649c494a2b4129b99a0 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 22:40:19 -0700 Subject: [PATCH 3/3] code review: no inlining on pool threads, a type initializer that cannot fail, spec forms From a report-only reviewer agent (high): - No inline attempt on a thread-pool thread: the pool's scheduler could hand the snippet's thread any task from its local queue (not necessarily the snippet's); other pool threads run those anyway. Godot/Unity main threads are unaffected. - The WrappedTryRunInline lookup cannot throw in the guard's type initializer (that would break every guarded snippet). - WaitAll offers its tasks in the framework's order (last to first). - Spec: names the forms that inline (not WaitAny), the pool-thread and missing-method cases, and that a non-snippet body run inline is stopped only when it returns. - Tests: a cold-compile-safe time bound, an inlined body that throws keeps its exception (not Timeout), WaitAll of a pool task plus a context task. Not taken: retrying the inline attempt between slices (the framework does not either). Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec.Tests/GuardTests.cs | 14 ++++++++++++-- dotnet/CodeExec/ExecutionGuard.cs | 25 ++++++++++++++++++------- spec/contract.md | 12 +++++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index 6535a47..6859c7e 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -398,12 +398,22 @@ public void AnUnboundedRunKeepsInlineTaskExecution() [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); - Assert.True(ms < 2500, $"took {ms} ms"); + } + + // 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: @@ -421,7 +431,7 @@ 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 < 2500, $"stopped after {ms} ms"); + 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). diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index 33738ca..37cbc9b 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -326,8 +326,8 @@ public static void TaskWaitAll(global::System.Collections.Generic.IEnumerable= 0; i--) // the framework's order + Inline(tasks[i]); return Sliced(millisecondsTimeout, ms => global::System.Threading.Tasks.Task.WaitAll(tasks, ms, cancellationToken)); } @@ -345,14 +345,25 @@ public static int TaskWaitAny(global::System.Threading.Tasks.Task[] tasks, int m // 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. Where the runtime lacks that - // method, every wait is sliced. - static readonly global::System.Reflection.MethodInfo _tryRunInline = typeof(global::System.Threading.Tasks.Task).GetMethod(""WrappedTryRunInline"", - global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.NonPublic, null, global::System.Type.EmptyTypes, null); + // 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) + 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) diff --git a/spec/contract.md b/spec/contract.md index 4baee2d..7b2705d 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -158,11 +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). A wait without bound and without a cancelable token for a task that is still queued first offers it to - its scheduler to run inline on the waiting thread, as the framework's wait does: 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, or one with no body to run (an async method's, whose continuation waits for the blocked thread), - is waited for in slices and stops at 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`,