Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions dotnet/CodeExec.Tests/GuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(() => 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<int>(() => 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<int> F() { await Task.Delay(20); return 5; }\nreturn F().Result;")]
[InlineData("async Task<int> 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
}

/// <summary>Runs the snippet on a thread whose synchronization context queues work to that same thread (a host's main thread).</summary>
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);
}

/// <summary>A host's main-thread context: posted work waits in a queue until that thread is free to run it.</summary>
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()
Expand Down
46 changes: 44 additions & 2 deletions dotnet/CodeExec/ExecutionGuard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,26 @@ 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<global::System.Threading.Tasks.Task> tasks, global::System.Threading.CancellationToken cancellationToken = default) =>
TaskWaitAll(new global::System.Collections.Generic.List<global::System.Threading.Tasks.Task>(tasks).ToArray(), -1, cancellationToken);
public static bool TaskWaitAll(global::System.Threading.Tasks.Task[] tasks, int millisecondsTimeout) => 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);
Expand All @@ -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
}
}

/// <summary>The task once it has completed (<c>.Result</c>, <c>.GetAwaiter().GetResult()</c> then return at once).</summary>
public static T Ready<T>(T task) where T : global::System.Threading.Tasks.Task
{
Inline(task);
if (task != null)
Sliced(-1, ms =>
{
Expand Down
10 changes: 7 additions & 3 deletions spec/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading