diff --git a/README.md b/README.md index b8e9da4..f5c8a95 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,11 @@ ExecuteResult result = await runner.ExecuteAsync(new ExecuteRequest { Code = "Ap - `ValueConverter` turns the result into something that outlives the snippet (Unity: ReflectorNet JSON). - Execution guards ([§3.6](spec/contract.md#36-execution-guards)): runaway recursion stops with an error before a stack overflow could end the host, and loops stop with `Timeout` once the time budget is spent, also in synchronous code - on the main thread. Budget: `TimeoutMs` on the request, else `DefaultTimeoutMs` (120 s); stack allowance - `MaxStackBytes` (1 MiB); `ExecutionGuards = false` turns them off. + on the main thread. Budget: `TimeoutMs` on the request, else `DefaultTimeoutMs` (120 s), at most `MaxTimeoutMs` + (0 = no cap); stack allowance `MaxStackBytes` (1 MiB); `ExecutionGuards = false` turns them off. Threads and tasks + the snippet starts (`new Thread`, `ThreadPool`, `Task.Run`, `StartNew`) stop once its run has ended. +- Agent tools: expose `timeoutMs` as a `timeout` argument, default to about 30 s, and cap it below the tool timeout + ([§1](spec/contract.md#1-request)). - Output is limited before it reaches the agent ([§6](spec/contract.md#6-output-limits)): a text value, the logs (first and last lines kept, 2k per line) and the error, 20k characters each by default (`MaxValueChars`, `MaxLogChars`, `MaxLogLineChars`, `MaxErrorChars` on the request; 0 = no limit). Includes stay inside `IncludeRoot` unless `AllowIncludeOutsideRoot`. @@ -77,7 +80,8 @@ code_exec.run("x = 21\nx * 2", include=["helpers"], include_root=".ai-game-dev/s code_exec.run_b64(base64_json_request) # JSON in, JSON out: one string literal from C++ code_exec.run("print('x')", max_log_chars=0) # the same limits as C# (JSON: maxLogChars, ...); 0 = no limit code_exec.run("while True: pass", timeout_ms=5000) # stopped by a watchdog: errorKind Timeout (JSON: timeoutMs) -code_exec.set_default_timeout_ms(60000) # the budget of requests without one (default 120000; 0 = none) +code_exec.set_default_timeout_ms(30000) # the budget of requests without one (default 120000; 0 = none) +code_exec.set_max_timeout_ms(280000) # the most a request may ask for (default 0 = no cap) code_exec.set_max_recursion_limit(3000) # Python < 3.12: the most a snippet's sys.setrecursionlimit sets (0 = no cap) ``` diff --git a/dotnet/CodeExec.Roslyn/src/GuardWriter.cs b/dotnet/CodeExec.Roslyn/src/GuardWriter.cs index dafe811..9b572a1 100644 --- a/dotnet/CodeExec.Roslyn/src/GuardWriter.cs +++ b/dotnet/CodeExec.Roslyn/src/GuardWriter.cs @@ -208,9 +208,113 @@ static void Collect(SyntaxNode node, List edits, Semantics? semantics) case TypeDeclarationSyntax type when type.IsKind(SyntaxKind.ClassDeclaration) || type.IsKind(SyntaxKind.RecordDeclaration): Initializers(type, edits); break; + case InvocationExpressionSyntax call when semantics != null && call.ArgumentList.Arguments.Count > 0 + && WorkStartNames.Contains(RightmostName(call.Expression)): + Workers(call, call.ArgumentList, semantics, edits); + break; + case ObjectCreationExpressionSyntax creation when semantics != null && creation.ArgumentList != null + && (RightmostName(creation.Type) == "Thread" || RightmostName(creation.Type) == "Task"): + Workers(creation, creation.ArgumentList, semantics, edits); + break; + case ImplicitObjectCreationExpressionSyntax creation when semantics != null: + Workers(creation, creation.ArgumentList, semantics, edits); + break; } } + /// The methods that start work on another thread or as a task (with the constructors of Thread and Task). + static readonly HashSet WorkStartNames = new HashSet { "Run", "StartNew", "QueueUserWorkItem", "UnsafeQueueUserWorkItem" }; + + static string RightmostName(SyntaxNode node) => node switch + { + MemberAccessExpressionSyntax member => member.Name.Identifier.ValueText, + QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + AliasQualifiedNameSyntax alias => alias.Name.Identifier.ValueText, + SimpleNameSyntax simple => simple.Identifier.ValueText, + _ => "", + }; + + /// + /// A delegate handed to a thread start becomes Worker((T)(delegate)) (), one + /// handed to a task start TaskWorker((T)(delegate)), cast to the delegate type it already converts to, so + /// overload resolution picks the same method: the work then belongs to the run and stops once the run has ended. + /// Not inside an expression tree or a query. + /// + static void Workers(ExpressionSyntax call, ArgumentListSyntax arguments, Semantics semantics, List edits) + { + if (semantics.HasErrors || !(semantics.Model.GetSymbolInfo(call).Symbol is IMethodSymbol method) || !(StartsWork(method) is bool task)) + return; + foreach (var outer in call.Ancestors()) + { + if (outer is QueryExpressionSyntax || (outer is AnonymousFunctionExpressionSyntax function && !semantics.IsDelegate(function))) + return; + } + var entry = task ? ExecutionGuard.TaskWorker : ExecutionGuard.Worker; + foreach (var argument in arguments.Arguments) + { + var expression = argument.Expression; + if (!argument.RefKindKeyword.IsKind(SyntaxKind.None) || !(semantics.Model.GetTypeInfo(expression).ConvertedType is INamedTypeSymbol type) + || !IsWorkDelegate(type, task) || !CanBeNamed(type)) + continue; + var cast = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + edits.Add(new Edit(expression.SpanStart, entry + "((" + cast + ")(", expression.SpanStart, edits.Count)); + edits.Add(new Edit(expression.Span.End, "))", expression.Span.End, edits.Count, closer: true)); + } + } + + /// Whether the method starts work: true for a task start, false for a thread start, null for neither. + static bool? StartsWork(IMethodSymbol method) + { + switch (method.ContainingType?.OriginalDefinition.ToDisplayString()) + { + case "System.Threading.Thread": + return method.MethodKind == MethodKind.Constructor ? false : (bool?)null; + case "System.Threading.ThreadPool": + return method.Name == "QueueUserWorkItem" || method.Name == "UnsafeQueueUserWorkItem" ? false : (bool?)null; + case "System.Threading.Tasks.Task": + return method.MethodKind == MethodKind.Constructor ? true : (bool?)null; + case "System.Threading.Tasks.Task": + return method.MethodKind == MethodKind.Constructor || method.Name == "Run" ? true : (bool?)null; + case "System.Threading.Tasks.TaskFactory": + case "System.Threading.Tasks.TaskFactory": + return method.Name == "StartNew" ? true : (bool?)null; + default: + return null; + } + } + + /// The delegate types (thread starts) or have an overload for. + static bool IsWorkDelegate(INamedTypeSymbol type, bool task) + { + switch (type.OriginalDefinition.ToDisplayString()) + { + case "System.Action": + return true; + case "System.Threading.ThreadStart": + case "System.Threading.ParameterizedThreadStart": + case "System.Threading.WaitCallback": + return !task; + case "System.Action": + case "System.Func": + case "System.Func": + return task; + default: + return false; + } + } + + /// Whether the type can be written in source: no anonymous or erroneous type anywhere in it. + static bool CanBeNamed(ITypeSymbol type) + { + if (type.IsAnonymousType || type.TypeKind == TypeKind.Error) + return false; + if (type is IArrayTypeSymbol array) + return CanBeNamed(array.ElementType); + if (type is IPointerTypeSymbol) + return false; + return !(type is INamedTypeSymbol named) || named.TypeArguments.All(CanBeNamed); + } + /// /// Field and property initializers run before any constructor body (and with no constructor at all): a class with /// them gets a first field of its own whose initializer runs the call check (static ones a static field). diff --git a/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs b/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs index a9c1f6f..4c49f4f 100644 --- a/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs +++ b/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs @@ -39,10 +39,27 @@ public Output(string source, TextSpan? returnedExpression, bool hasOtherReturns) public bool HasOtherReturns { get; } } - public static Output Write(string code, IReadOnlyList usings, IReadOnlyList parameters, bool returnLastExpression) + public static Output Write(string code, IReadOnlyList usings, IReadOnlyList parameters, bool returnLastExpression) => + Write(code, usings, parameters, returnLastExpression, findDeclarations: true); + + static Output Write(string code, IReadOnlyList usings, IReadOnlyList parameters, bool returnLastExpression, bool findDeclarations) { var scriptOptions = new CSharpParseOptions(LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Script); - var root = (CompilationUnitSyntax)CSharpSyntaxTree.ParseText(code, scriptOptions).GetRoot(); + // Script code takes a top-level `using` as a directive: the `[await] using` of each top-level using declaration + // is blanked out before parsing (positions stay) and put back when its declaration is written. + var declarations = findDeclarations ? UsingDeclarations(code, scriptOptions) : new Dictionary(); + var parsed = code; + if (declarations.Count > 0) + { + var chars = code.ToCharArray(); + foreach (var d in declarations.Values) + for (var i = d.Start; i < d.KeywordEnd; i++) + if (!char.IsWhiteSpace(chars[i])) + chars[i] = ' '; + parsed = new string(chars); + } + var tree = CSharpSyntaxTree.ParseText(parsed, scriptOptions); + var root = (CompilationUnitSyntax)tree.GetRoot(); // Body = statements, plus members WITHOUT modifiers: `var x = 1;` (a script field) becomes a local and // `int F() => x;` a local function, so both see the snippet's locals. Members with modifiers become static @@ -64,21 +81,26 @@ public static Output Write(string code, IReadOnlyList usings, IReadOnlyL else topLevel.Add(member); } + // A declaration that did not become a statement (`using readonly T x = ...;`) keeps its `using`: its error is the agent's. + if (declarations.Keys.Any(start => !body.Any(b => b.Node.SpanStart == start))) + return Write(code, usings, parameters, returnLastExpression, findDeclarations: false); var sb = new StringBuilder(); - foreach (var e in root.Externs) // extern aliases must precede every using + foreach (var e in root.Externs) // extern aliases must precede every using, global usings every other one Emit(sb, e.GetLocation(), e.ToString()); + foreach (var u in root.Usings.Where(IsGlobal)) + Emit(sb, u.GetLocation(), u.ToString()); // The runtime's usings (host defaults + request) map to their index, so a bad one is reported as itself. for (var i = 0; i < usings.Count; i++) sb.Append("#line ").Append(i + 1).Append(" \"").Append(RoslynCSharpCompiler.UsingsFile).Append("\"\nusing ").Append(usings[i]).Append(";\n"); - foreach (var u in root.Usings) + foreach (var u in root.Usings.Where(u => !IsGlobal(u))) Emit(sb, u.GetLocation(), u.ToString()); foreach (var t in topLevel) Emit(sb, t.GetLocation(), t.ToString()); // One pass over the snippet's own scope (nested functions have their own `return` / `await`): `await` // expressions, `await foreach` and `await using` all carry the await keyword. - bool isAsync = false, hasValueReturns = false, hasReturns = false; + bool isAsync = declarations.Values.Any(d => d.Await), hasValueReturns = false, hasReturns = false; foreach (var (node, _) in body) { foreach (var item in node.DescendantNodesAndTokensAndSelf(n => !IsNestedFunction(n))) @@ -126,6 +148,12 @@ public static Output Write(string code, IReadOnlyList usings, IReadOnlyL for (var i = 0; i < body.Count; i++) { var (node, text) = body[i]; + if (declarations.TryGetValue(node.SpanStart, out var declaration)) + { + // The agent's own text from `[await] using` on: a declaration has no `return` of its own to rewrite. + Emit(sb, Location.Create(tree, new TextSpan(declaration.Start, 0)), code.Substring(declaration.Start, node.Span.End - declaration.Start)); + continue; + } if (returnsValue && node is StatementSyntax statement) text = new BareReturnRewriter().Visit(statement)!.ToString(); if (node is ExpressionStatementSyntax expression) @@ -152,6 +180,101 @@ public static Output Write(string code, IReadOnlyList usings, IReadOnlyL return new Output(sb.ToString(), returned, hasReturns); } + /// A top-level [await] using declaration: where its keywords start and end. + readonly struct UsingDeclaration + { + public UsingDeclaration(int start, int keywordEnd, bool isAwait) + { + Start = start; + KeywordEnd = keywordEnd; + Await = isAwait; + } + + public int Start { get; } + public int KeywordEnd { get; } + public bool Await { get; } + } + + /// + /// The top-level using declarations (using var x = ...;, await using T x = ...;), by the position of the + /// declaration after their keywords. A using that starts a statement is a directive only as using static, + /// using unsafe, an alias (using A = ...;) or a name with no = before its ;; using ( + /// is a using statement, which script code already parses as one. + /// + static Dictionary UsingDeclarations(string code, CSharpParseOptions options) + { + var found = new Dictionary(); + if (code.IndexOf("using", System.StringComparison.Ordinal) < 0) + return found; + var tokens = SyntaxFactory.ParseTokens(code, options: options).ToList(); + var depth = 0; + for (var i = 0; i < tokens.Count; i++) + { + var kind = tokens[i].Kind(); + if (kind == SyntaxKind.OpenBraceToken || kind == SyntaxKind.OpenParenToken || kind == SyntaxKind.OpenBracketToken) + depth++; + else if (kind == SyntaxKind.CloseBraceToken || kind == SyntaxKind.CloseParenToken || kind == SyntaxKind.CloseBracketToken) + depth = System.Math.Max(depth - 1, 0); + if (depth != 0 || kind != SyntaxKind.UsingKeyword) + continue; + var first = i > 0 && tokens[i - 1].Text == "await" ? i - 1 : i; + if (first > 0 && !tokens[first - 1].IsKind(SyntaxKind.SemicolonToken) && !tokens[first - 1].IsKind(SyntaxKind.CloseBraceToken)) + continue; // not at the start of a statement (`global using`, ...) + if (i + 1 < tokens.Count && IsDeclaration(tokens, i + 1)) + found[tokens[i + 1].SpanStart] = new UsingDeclaration(tokens[first].SpanStart, tokens[i].Span.End, first != i); + } + return found; + } + + /// Whether the tokens after a statement-starting using (from ) declare variables. + /// + /// Type name =: the variable's name right before the first = outside brackets, a type before it with no + /// two names in a row (using System missing its ; before var x = 1; stays a directive). A keyword + /// other than a predefined type (static, unsafe, a modifier) means a directive or invalid code. + /// + static bool IsDeclaration(List tokens, int at) + { + var next = tokens[at].Kind(); + if (next == SyntaxKind.OpenParenToken || (SyntaxFacts.IsKeywordKind(next) && !SyntaxFacts.IsPredefinedType(next))) + return false; + var depth = 0; + for (var i = at; i < tokens.Count; i++) + { + switch (tokens[i].Kind()) + { + case SyntaxKind.OpenParenToken: + case SyntaxKind.OpenBracketToken: + depth++; + break; + case SyntaxKind.CloseParenToken: + case SyntaxKind.CloseBracketToken: + depth--; + break; + case SyntaxKind.EqualsToken when depth == 0: + return i - 1 > at && tokens[i - 1].IsKind(SyntaxKind.IdentifierToken) && !HasAdjacentNames(tokens, at, i - 1); + case SyntaxKind.SemicolonToken: + case SyntaxKind.OpenBraceToken: + case SyntaxKind.CloseBraceToken: + case SyntaxKind.EndOfFileToken: + return false; + } + } + return false; + } + + /// Two names in a row in tokens [, ): not one type. + static bool HasAdjacentNames(List tokens, int from, int to) + { + for (var i = from + 1; i < to; i++) + if (IsName(tokens[i - 1]) && IsName(tokens[i])) + return true; + return false; + } + + static bool IsName(SyntaxToken token) => token.IsKind(SyntaxKind.IdentifierToken) || SyntaxFacts.IsPredefinedType(token.Kind()); + + static bool IsGlobal(UsingDirectiveSyntax u) => u.GetFirstToken().IsKind(SyntaxKind.GlobalKeyword); + /// /// Appends mapped to the agent's line, indented to the agent's column so diagnostics keep /// both; returns where the text starts. diff --git a/dotnet/CodeExec.Tests/ChildProcess.cs b/dotnet/CodeExec.Tests/ChildProcess.cs index 50df653..5667e9f 100644 --- a/dotnet/CodeExec.Tests/ChildProcess.cs +++ b/dotnet/CodeExec.Tests/ChildProcess.cs @@ -16,19 +16,24 @@ static class ChildProcess public static int Main(string[] args) { - if (args.Length != 2 || args[0] != Flag) + if (args.Length != 4 || args[0] != Flag) { - Console.Error.WriteLine("usage: CodeExec.Tests " + Flag + " "); + Console.Error.WriteLine("usage: CodeExec.Tests " + Flag + " "); return 2; } var code = Encoding.UTF8.GetString(Convert.FromBase64String(args[1])); - var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = code }); + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = code, TimeoutMs = int.Parse(args[2]) }); Console.Out.Write(result.ErrorKind + "\n" + result.Error); + Console.Out.Flush(); + System.Threading.Thread.Sleep(int.Parse(args[3])); // what the snippet left running gets the time to end the process return 0; } - /// The child's and error text; fails when the child did not end normally. - public static (string ErrorKind, string Error) Execute(string code) + /// + /// The child's and error text; fails when the child did not end normally, also when what the + /// snippet left running ends it within or keeps it from exiting. + /// + public static (string ErrorKind, string Error) Execute(string code, int timeoutMs = 0, int lingerMs = 0) { // The build output, not Assembly.Location: the .NET Framework test host runs a shadow copy without its dependencies. var assembly = Fixtures.Path(typeof(ChildProcess).Assembly.GetName().Name + ".dll"); @@ -41,10 +46,10 @@ public static (string ErrorKind, string Error) Execute(string code) var snippet = Convert.ToBase64String(Encoding.UTF8.GetBytes(code)); #if NETFRAMEWORK start.FileName = Path.ChangeExtension(assembly, ".exe"); - start.Arguments = Flag + " " + snippet; + start.Arguments = Flag + " " + snippet + " " + timeoutMs + " " + lingerMs; #else start.FileName = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } host ? host : "dotnet"; - start.Arguments = "exec \"" + assembly + "\" " + Flag + " " + snippet; + start.Arguments = "exec \"" + assembly + "\" " + Flag + " " + snippet + " " + timeoutMs + " " + lingerMs; #endif using var process = Process.Start(start)!; var stderr = process.StandardError.ReadToEndAsync(); diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index 2e69a37..728a3ae 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -168,6 +168,24 @@ public void HostDefaultBudgetAppliesWhenTheRequestHasNone() Assert.Equal(ErrorKind.InvalidRequest, negative.ErrorKind); } + [Fact] + public void HostCapsTheBudget() + { + var runner = Fixtures.Runner(configure: o => o.MaxTimeoutMs = 300); + foreach (var timeoutMs in new[] { 0, 60_000 }) // the host default (120 s) and a longer request both get the cap + { + var watch = Stopwatch.StartNew(); + var result = runner.Execute(new ExecuteRequest { Code = "while (true) { }", TimeoutMs = timeoutMs }); + Assert.Equal(ErrorKind.Timeout, result.ErrorKind); + Assert.Contains("300 ms", result.Error); + Assert.InRange(watch.ElapsedMilliseconds, 250, 5000); + } + Assert.Contains("100 ms", runner.Execute(new ExecuteRequest { Code = "while (true) { }", TimeoutMs = 100 }).Error); // a shorter one stays + var unbounded = Fixtures.Runner(configure: o => { o.DefaultTimeoutMs = 0; o.MaxTimeoutMs = 200; }); + Assert.Contains("200 ms", unbounded.Execute(new ExecuteRequest { Code = "while (true) { }" }).Error); + Assert.Equal(0, new CodeRunnerOptions().MaxTimeoutMs); + } + [Fact] public void ReusedAssemblyIsReArmedOnEveryRunButATimedOutOneIsNotReused() { @@ -202,6 +220,106 @@ public void FinishedRunLiftsTheBudgetForCallbacksItLeftBehind() Assert.Equal(45, callback()); } + // Work a snippet starts on another thread belongs to its run: once the run has ended, it stops at its next check. + // Without that, each of these spins forever after the run (the test then times out waiting for it). + [Theory] + [InlineData("var t = new System.Threading.Thread(() => { while (true) { } }) { IsBackground = true };\nt.Start();\nreturn (Func)(() => t.Join(10_000));")] + [InlineData("void Spin() { var n = 0L; while (n >= 0) n++; }\nvar t = new System.Threading.Thread(Spin) { IsBackground = true };\nt.Start();\nreturn (Func)(() => t.Join(10_000));")] + [InlineData("var t = new System.Threading.Thread(o => { while (o != null) { } }) { IsBackground = true };\nt.Start(1);\nreturn (Func)(() => t.Join(10_000));")] + // Waits until the work runs inside its try: work that starts only after the run has ended stops at its first check. + [InlineData("var started = new System.Threading.ManualResetEvent(false);\nvar done = new System.Threading.ManualResetEvent(false);\nSystem.Threading.ThreadPool.QueueUserWorkItem(_ => { try { started.Set(); while (true) { } } finally { done.Set(); } });\nstarted.WaitOne();\nreturn (Func)(() => done.WaitOne(10_000));")] + [InlineData("var t = Task.Run(() => { while (true) { } });\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000));")] + [InlineData("var t = Task.Run(() => { var n = 0; while (true) n++; return n; });\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000) && t.Exception!.InnerException is TimeoutException);")] + [InlineData("var t = Task.Run(async () => { while (true) await Task.Yield(); });\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000));")] + [InlineData("var t = Task.Factory.StartNew(() => { while (true) { } }, TaskCreationOptions.LongRunning);\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000));")] + [InlineData("var t = new Task(() => { while (true) { } });\nt.Start();\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000));")] + // Asynchronous task work belongs to the run until its task ends, also when its first await returned before the run ended. + [InlineData("var t = Task.Run(async () => { await Task.Delay(200); while (true) { } });\nSystem.Threading.Thread.Sleep(50);\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000));")] + // Code that asynchronous task work starts without a wrapper (a fire-and-forget async method) carries the run on. + [InlineData("class K { public static System.Threading.ManualResetEvent Done = new System.Threading.ManualResetEvent(false); public static async Task Spin() { try { await Task.Delay(300); while (true) { } } finally { Done.Set(); } } }\nTask.Run(async () => { _ = K.Spin(); await Task.Yield(); }).Wait();\nreturn (Func)(() => K.Done.WaitOne(10_000));")] + // Started by work the run started: it belongs to the same run. + [InlineData("var inner = new TaskCompletionSource();\nTask.Run(() => inner.SetResult(Task.Run(() => { while (true) { } })));\nvar t = inner.Task.Result;\nreturn (Func)(() => ((IAsyncResult)t).AsyncWaitHandle.WaitOne(10_000));")] + // Caught: the stop passes the agent's catch-all. + [InlineData("var t = new System.Threading.Thread(() => { while (true) { try { System.Threading.Thread.Sleep(1); } catch (Exception) { } } }) { IsBackground = true };\nt.Start();\nreturn (Func)(() => t.Join(10_000));")] + public void WorkTheSnippetStartedStopsWhenItsRunEnds(string code) + { + // The test takes the snippet's own delegate out (a host must not): it waits for the work after the run. + var result = Fixtures.Runner(configure: o => o.ValueConverter = value => value).Execute(new ExecuteRequest { Code = code }); + Assert.True(result.Success, result.Error + " " + string.Join(" | ", result.Diagnostics)); + Assert.True(((Func)result.Value!)(), "the work is still running after its run ended"); + } + + [Theory] + [InlineData("var t = new System.Threading.Thread(() => { while (true) { } });\nt.Start();\nt.Join();\n1")] + [InlineData("Task.Run(() => { while (true) { } }).ContinueWith(_ => { }).Wait();\n1")] + // A void task entry faults with the stop (only thread entries swallow it): the snippet does not carry on after Wait. + [InlineData("var stop = false;\nTask.Run(() => { while (!stop) { } }).Wait();\nConsole.WriteLine(\"after\");")] + public void BudgetSpentInWorkTheSnippetWaitsForIsATimeout(string code) + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = code, TimeoutMs = 300 }); + Assert.True(ErrorKind.Timeout == result.ErrorKind, $"{result.ErrorKind}: {result.Error}"); + Assert.Contains("The snippet ran longer than its time budget of 300 ms (timeoutMs)", result.Error); + Assert.DoesNotContain(result.Logs, l => l.Message == "after"); + } + + [Fact] + public void RecursionStoppedInAThreadTheSnippetWaitsForIsAnError() + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = "int R(int n) => R(n + 1) + 1;\nvar t = new System.Threading.Thread(() => { R(0); });\nt.Start();\nt.Join();\nreturn 5;" }); + Assert.Equal(ErrorKind.Runtime, result.ErrorKind); + Assert.StartsWith("System.InsufficientExecutionStackException: Recursion too deep", result.Error); + } + + [Fact] + public void WorkerFramesAreNotTheAgents() + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = "Task.Run(() => { throw new InvalidOperationException(\"w\"); }).Wait();" }); + Assert.Equal(ErrorKind.Runtime, result.ErrorKind); + Assert.Contains("InvalidOperationException: w", result.Error); + Assert.Contains("snippet.cs:line 1", result.Error); + Assert.DoesNotContain(ExecutionGuard.TypeName, result.Error); + } + + [Fact] + public void UnfinishedWorkIsReportedAsAWarning() + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = "new System.Threading.Thread(() => { while (true) { } }) { IsBackground = true }.Start();\n1" }); + Assert.True(result.Success, result.Error); + Assert.Contains(result.Logs, l => l.Level == "warning" && l.Message.StartsWith("1 thread(s) or task(s) the snippet started had not finished")); + var joined = Fixtures.Runner().Execute(new ExecuteRequest { Code = "var t = new System.Threading.Thread(() => { });\nt.Start();\nt.Join();\n1" }); + Assert.Empty(joined.Logs); + } + + // A stop on a thread the snippet started would end the process if it went unhandled there. In a child process. + [Theory] + [InlineData("new System.Threading.Thread(() => { while (true) { } }).Start();\nwhile (true) { }", "Timeout")] + [InlineData("System.Threading.ThreadPool.QueueUserWorkItem(_ => { while (true) { } });\nwhile (true) { }", "Timeout")] + // Nothing but the work itself carries the run: an async void continuation or a timer the work created is not + // stopped (an exception unhandled there would end the process). + [InlineData("new System.Threading.Thread(() => System.Threading.Thread.Sleep(3000)) { IsBackground = true }.Start();\nnew System.Threading.Thread(async () => { await Task.Delay(100); for (var i = 0; i < 1000; i++) { } }) { IsBackground = true }.Start();\n1", "None")] + [InlineData("new System.Threading.Thread(() => System.Threading.Thread.Sleep(3000)) { IsBackground = true }.Start();\nSystem.Threading.ThreadPool.QueueUserWorkItem(async _ => { await Task.Delay(100); for (var i = 0; i < 1000; i++) { } });\n1", "None")] + [InlineData("class Keep { public static object T; }\nnew System.Threading.Thread(() => System.Threading.Thread.Sleep(3000)) { IsBackground = true }.Start();\nTask.Run(() => { Keep.T = new System.Threading.Timer(_ => { for (int i = 0; i < 10; i++) { } }, null, 300, 100); }).Wait();\n1", "None")] + // A foreground thread left spinning would also keep the process from exiting. + [InlineData("new System.Threading.Thread(() => { while (true) { } }).Start();\n1", "None")] + public void StoppedThreadsNeverEndTheProcess(string code, string kind) + { + var (errorKind, error) = ChildProcess.Execute(code, timeoutMs: 300, lingerMs: 1000); + Assert.True(errorKind == kind, errorKind + ": " + error); + } + + [Fact] + public void WorkACallbackStartsAfterTheRunIsItsOwn() + { + var runner = Fixtures.Runner(configure: o => o.ValueConverter = value => value); + var result = runner.Execute(new ExecuteRequest + { + Code = "return (Func)(() => Task.Run(() => { var watch = System.Diagnostics.Stopwatch.StartNew(); var n = 0; while (watch.ElapsedMilliseconds < 300) n++; return 1; }).Result);", + TimeoutMs = 100, + }); + Assert.True(result.Success, result.Error); + Assert.Equal(1, ((Func)result.Value!)()); + } + [Fact] public void GuardsCanBeTurnedOff() { @@ -244,6 +362,18 @@ public void GuardsCanBeTurnedOff() [InlineData("Func s = static x => x + 1;\ns(1)", "2")] [InlineData("using TI = System.Threading.Tasks.Task;\nclass C { public async TI F() => await Task.FromResult(1); }\nawait new C().F()", "1")] [InlineData("int[] arr = { 1 };\nFunc one = () => arr[0];\n$\"{one()}{((Func)(() => 2))()}\"", "12")] + // Work handed to a thread or task start is wrapped, cast to the delegate type it had: the same overload, the same value. + [InlineData("var x = 0;\nvar t = new System.Threading.Thread(() => x = 5);\nt.Start();\nt.Join();\nx", "5")] + [InlineData("var x = 0;\nSystem.Threading.Thread t = new(() => { x = 6; });\nt.Start();\nt.Join();\nx", "6")] + [InlineData("var done = new System.Threading.ManualResetEvent(false);\nvar x = 0;\nSystem.Threading.ThreadPool.QueueUserWorkItem(o => { x = (int)o!; done.Set(); }, 7);\ndone.WaitOne();\nx", "7")] + [InlineData("Task.Run(async () => { await Task.Delay(1); return 8; }).Result", "8")] + [InlineData("await Task.Run(async () => await Task.FromResult(9))", "9")] + [InlineData("Task.Factory.StartNew(o => (int)o! * 2, 5).Result", "10")] + [InlineData("Task.Run(() => new { A = 11 }).Result.A", "11")] + [InlineData("int Twelve() => 12;\nTask.Run(Twelve).Result", "12")] + [InlineData("Func f = null;\ntry { Task.Run(f); } catch (ArgumentNullException) { return 13; }\nreturn 0;", "13")] + [InlineData("try { Task.Run(() => { throw new InvalidOperationException(\"w\"); }).Wait(); } catch (AggregateException e) { return e.InnerException!.Message; }\nreturn \"\";", "w")] + [InlineData("System.Linq.Expressions.Expression>> e = () => Task.Run(() => 1);\ne.Body.ToString().Contains(\"Worker\")", "False")] public async Task GuardedCodeKeepsItsMeaning(string code, string expected) { var result = await Fixtures.Runner().ExecuteAsync(new ExecuteRequest { Code = code }); diff --git a/dotnet/CodeExec.Tests/RunnerTests.cs b/dotnet/CodeExec.Tests/RunnerTests.cs index e950bd6..777f8f6 100644 --- a/dotnet/CodeExec.Tests/RunnerTests.cs +++ b/dotnet/CodeExec.Tests/RunnerTests.cs @@ -282,7 +282,73 @@ public void ExternAliasGoesBeforeTheUsings() Assert.DoesNotContain(result.Diagnostics, d => d.Id == "CS0439"); } + // A using declaration or statement is a statement wherever it stands, also first: only `using X;`, `using static`, + // `using A = B;` and `global using` are directives (spec §3.1). + [Theory] + [InlineData("using var w = new System.IO.StringWriter();\nw.Write(1);\nw.ToString()", "1")] + [InlineData("using System.IO.StringWriter w = new System.IO.StringWriter();\nw.Write(2);\nw.ToString()", "2")] + [InlineData("using var a = new System.IO.StringWriter(); using var b = new System.IO.StringWriter();\na.Write(3);\na.ToString() + b", "3")] + [InlineData("var x = 4;\nusing var w = new System.IO.StringWriter();\nw.Write(x);\nw.ToString()", "4")] + [InlineData("using System.IO;\nusing var w = new StringWriter();\nw.Write(5);\nw.ToString()", "5")] + [InlineData("using\n var w = new System.IO.StringWriter();\nw.Write(6);\nw.ToString()", "6")] + [InlineData("using System.IO.StringWriter a = new System.IO.StringWriter(), b = new System.IO.StringWriter();\nb.Write(7);\nb.ToString()", "7")] + [InlineData("using (var w = new System.IO.StringWriter()) { w.Write(8); return w.ToString(); }", "8")] + [InlineData("using (new System.IO.StringWriter()) { }\n9", "9")] + [InlineData("using static System.Math;\nusing V = System.Version;\nusing var w = new System.IO.StringWriter();\nMax(new V(1, 10).Minor, 2)", "10")] + [InlineData("using var w = new System.IO.StringWriter();\nint F() { using var inner = new System.IO.StringWriter(); return 11; }\nF()", "11")] + // Disposed when the snippet ends, after the result is taken (the returned object is converted afterwards). + [InlineData("class D : IDisposable { readonly System.Text.StringBuilder _log; public D(System.Text.StringBuilder log) => _log = log; public void Dispose() => _log.Append(\"disposed\"); }\nvar log = new System.Text.StringBuilder();\nusing var d = new D(log);\nlog.Append(\"body;\");\nlog", "body;disposed")] + public void UsingDeclarationsAndStatementsStayStatements(string code, string expected) + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = code }); + Assert.True(result.Success, result.Error + " " + string.Join(" | ", result.Diagnostics)); + Assert.Equal(expected, result.Value); + } + + [Theory] + [InlineData("using var w = new System.IO.StringWriter(); var z = undefinedThing;", 1, 53)] + [InlineData("using var w = undefinedThing;", 1, 15)] + [InlineData("var a = 1;\n using var w = new System.IO.StringWriter();\nvar z = undefinedThing;", 3, 9)] + public void UsingDeclarationsKeepTheAgentsCoordinates(string code, int line, int column) + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = code, AutoImport = false }); + Assert.Equal(ErrorKind.Compile, result.ErrorKind); + var d = Assert.Single(result.Diagnostics); + Assert.Equal(("CS0103", "snippet.cs", line, column), (d.Id, d.File, d.Line, d.Column)); + } + + // Invalid code keeps its own errors: no `using` is dropped, a directive missing its `;` stays a directive. + [Theory] + [InlineData("using readonly System.IO.StringWriter w = new System.IO.StringWriter();\n1", "CS1041", 1, 6)] + [InlineData("using System\nvar x = 1;\nx", "CS1002", 1, 13)] + public void InvalidUsingsKeepTheirOwnErrors(string code, string id, int line, int column) + { + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = code, AutoImport = false }); + Assert.Equal(ErrorKind.Compile, result.ErrorKind); + Assert.Contains(result.Diagnostics, d => d.Id == id && d.Line == line && d.Column == column); + } + + [Fact] + public void GlobalUsingGoesBeforeTheRuntimesUsings() + { + if (typeof(Microsoft.CodeAnalysis.CSharp.CSharpCompilation).Assembly.GetName().Version!.Major < 4) + return; // C# 10 + var result = Fixtures.Runner().Execute(new ExecuteRequest { Code = "global using System.Text;\nusing var w = new System.IO.StringWriter();\nnew StringBuilder(\"g\").ToString()" }); + Assert.True(result.Success, result.Error + " " + string.Join(" | ", result.Diagnostics)); + Assert.Equal("g", result.Value); + } + #if NET8_0_OR_GREATER + [Theory] + [InlineData("await using var m = new System.IO.MemoryStream();\nm.Length", "0")] + [InlineData("await using System.IO.MemoryStream m = new System.IO.MemoryStream(new byte[3]);\nm.Length", "3")] + public async Task AwaitUsingDeclarationMakesTheSnippetAsync(string code, string expected) + { + var result = await Fixtures.Runner().ExecuteAsync(new ExecuteRequest { Code = code }); + Assert.True(result.Success, result.Error + " " + string.Join(" | ", result.Diagnostics)); + Assert.Equal(expected, result.Value); + } + [Theory] [InlineData("static async IAsyncEnumerable G() { await Task.Yield(); yield return 2; }\nint s = 0;\nawait foreach (var x in G()) s += x;\ns", "2")] [InlineData("long n;\nawait using (var d = new System.IO.MemoryStream()) { n = d.Length; }\nn", "0")] diff --git a/dotnet/CodeExec/CodeRunner.cs b/dotnet/CodeExec/CodeRunner.cs index cb87113..b7b8dee 100644 --- a/dotnet/CodeExec/CodeRunner.cs +++ b/dotnet/CodeExec/CodeRunner.cs @@ -50,6 +50,13 @@ public sealed class CodeRunnerOptions /// unless the host sets another: 120 s. public const int DefaultTimeout = 120_000; + /// + /// The highest time budget a request may ask for (spec §1): a longer , and a + /// run that would have no bound, get this one. 0 = no cap (default). A host that lets agents pick the budget sets it + /// below its own tool timeout, so the snippet's Timeout answers before the host gives up. + /// + public int MaxTimeoutMs { get; set; } + /// /// Compile the execution guards into every snippet (spec §3.6): runaway recursion is stopped before a stack /// overflow ends the process, and loops are stopped once the time budget is spent, also on the host's main @@ -330,8 +337,13 @@ public async Task RunAsync(PreparedSnippet prepared, Cancellation return run.Finish(); } - /// The request's , else the host's default; 0 = no bound. - int TimeBudget(ExecuteRequest request) => request.TimeoutMs > 0 ? request.TimeoutMs : Math.Max(_options.DefaultTimeoutMs, 0); + /// The request's , else the host's default, at most the host's cap; 0 = no bound. + int TimeBudget(ExecuteRequest request) + { + var budget = request.TimeoutMs > 0 ? request.TimeoutMs : Math.Max(_options.DefaultTimeoutMs, 0); + var max = _options.MaxTimeoutMs; + return max > 0 && (budget == 0 || budget > max) ? max : budget; + } Execution Start(PreparedSnippet prepared) { @@ -489,6 +501,14 @@ internal static string FormatException(Exception exception, Assembly? snippet) return sb.ToString(); } + /// The guard or one of its nested types (the closures of its thread and task wrappers). + static bool IsGuard(Type type) + { + while (type.DeclaringType != null) + type = type.DeclaringType; + return type.FullName == ExecutionGuard.TypeName; + } + static void AppendException(StringBuilder sb, Exception exception, Assembly? snippet) { string message; @@ -503,7 +523,7 @@ static void AppendException(StringBuilder sb, Exception exception, Assembly? sni foreach (var frame in new StackTrace(exception, true).GetFrames() ?? Array.Empty()) { var method = frame.GetMethod(); - if (method?.DeclaringType?.Assembly != snippet || method.DeclaringType.FullName == ExecutionGuard.TypeName) + if (method?.DeclaringType?.Assembly != snippet || IsGuard(method.DeclaringType)) continue; var line = " at " + method.DeclaringType.FullName + "." + method.Name; var file = frame.GetFileName(); @@ -665,18 +685,24 @@ public Execution(CodeRunner runner, PreparedSnippet prepared) /// The snippet's execution guard (spec §3.6), when it was compiled with one. Type? _guard; + object? _run; + int _budgetMs; public bool Guarded => _guard != null; /// Starts the guard's time budget for this run (0 = none). public void Arm(int budgetMs) { _guard = ExecutionGuard.Find(Loaded!.Assembly); - if (_guard != null) - ExecutionGuard.Arm(_guard, budgetMs, Math.Max(_runner._options.MaxStackBytes, 0)); + if (_guard == null) + return; + ExecutionGuard.Arm(_guard, budgetMs, Math.Max(_runner._options.MaxStackBytes, 0)); + _run = ExecutionGuard.Begin(_guard); + _budgetMs = budgetMs; } /// - /// After the run: a finished one lifts the time budget (callbacks the snippet left behind run on, still with the + /// After the run: the threads and tasks it started stop at their next check (a warning says how many had not + /// finished). A finished run lifts the time budget (callbacks the snippet left behind run on, still with the /// stack check). A timed-out one keeps its stop for whatever still runs, and on a host that cannot unload, its /// assembly is never reused, so no later run re-arms that guard. /// @@ -684,6 +710,14 @@ void Disarm() { if (_guard == null) return; + try + { + var pending = _run == null ? 0 : ExecutionGuard.End(_guard, _run); + if (pending > 0) + _logs.Add(new LogEntry("warning", $"{pending} thread(s) or task(s) the snippet started had not finished when it ended: " + + "they are stopped at their next loop iteration or call. Join or await work the snippet starts.")); + } + catch (Exception) { } // a guard that cannot be reached: nothing to end if (_errorKind == ErrorKind.Timeout) { _runner.Forget(_prepared.Entry!); @@ -742,6 +776,15 @@ public ExecuteResult Finish() object? value = null; try { + // A guard stopped a thread the snippet started while the snippet itself waited for it (Join): the thread + // swallowed the stop, but the run is no success either. + var stopped = _errorKind == ErrorKind.None && _guard != null ? ExecutionGuard.Stopped(_guard) : 0; + if (stopped == ExecutionGuard.StoppedByTime) + Fail(ErrorKind.Timeout, $"System.TimeoutException: The snippet ran longer than its time budget of {_budgetMs} ms (timeoutMs); " + + "a thread or task it started was stopped."); + else if (stopped != 0) + Fail(ErrorKind.Runtime, "System.InsufficientExecutionStackException: Recursion too deep in a thread the snippet started: " + + "it was stopped before a stack overflow could end the host process; bound the recursion depth or use a loop."); if (_errorKind == ErrorKind.None) value = _runner._options.ValueConverter(Result()); } diff --git a/dotnet/CodeExec/Contracts.cs b/dotnet/CodeExec/Contracts.cs index 3560159..91209e5 100644 --- a/dotnet/CodeExec/Contracts.cs +++ b/dotnet/CodeExec/Contracts.cs @@ -52,7 +52,8 @@ public sealed class ExecuteRequest /// /// The snippet's time budget in ms (spec §1): it stops with once the budget is spent /// (execution guards, spec §3.6), and an asynchronous result is awaited no longer. 0 = the host's - /// ; negative = InvalidRequest. + /// ; at most ; + /// negative = InvalidRequest. /// public int TimeoutMs { get; set; } diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index 0ca4018..7caf693 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -48,6 +48,17 @@ public static class ExecutionGuard /// public const string CatchFilter = "global::" + TypeName + ".Running()"; + /// + /// The method that wraps a delegate the agent's code hands to a thread or task start (new Thread(...), + /// ThreadPool.QueueUserWorkItem, Task.Run, Task.Factory.StartNew, new Task(...)), cast + /// to its exact delegate type: Worker((T)(delegate)). The work then belongs to the run that started it and is + /// stopped once that run has ended (spec §3.6). + /// + public const string Worker = "global::" + TypeName + ".Worker"; + + /// Like , for a task start (Task.Run, StartNew, new Task): the task faults with a stop. + public const string TaskWorker = "global::" + TypeName + ".TaskWorker"; + internal const int StoppedByTime = 1; /// @@ -59,14 +70,29 @@ public static class ExecutionGuard /// call, and on a loop's first iteration, then after 2, 4, ... up to 256 iterations while checks are less than /// 10 ms apart, back to every iteration when they are not: slow iterations overshoot the budget by about 20 ms, /// fast ones pay one clock read per 256 iterations. The statics are shared by all threads: a race only shifts a - /// check. + /// check. Work handed to a thread or task start (, ) carries its run: a + /// thread-static while it runs synchronously, an AsyncLocal for asynchronous task work (it flows into the + /// work's awaits); the tasks and threads it starts in turn belong to the same run. Once a run that started work has + /// ended, every clock check reads it, and work of an ended run stops there. A thread entry swallows the stop: an + /// exception left unhandled on a thread would end the process. /// public const string Source = @"#line hidden internal static class __CodeExecGuard { public static int Calls, Jumps; - static int _start, _last, _interval, _budget, _stopped, _maxStack, _baseThread; + static int _start, _last, _interval, _budget, _stopped, _maxStack, _baseThread, _orphans, _asyncOrphans, _asyncWork, _live; static long _base; + static Run _current; + // The run of the work running: synchronous work on this thread, and asynchronous task work (it flows into the work's + // awaits). Nothing else carries it: a timer, an async void continuation or a thread started by work keeps none of it. + [global::System.ThreadStatic] static Run _thread; + static readonly global::System.Threading.AsyncLocal _async = new global::System.Threading.AsyncLocal(); + + /// One run: whether it has ended, whether it started work, and how much of that work has not finished. + public sealed class Run + { + public int Ended, Spawned, Pending, Async; + } public static unsafe void Arm(int budgetMs, int maxStackBytes) { @@ -82,6 +108,20 @@ public static unsafe void Arm(int budgetMs, int maxStackBytes) public static int Stopped() => _stopped; + public static object Begin() => _current = new Run(); + + /// Ends a run: its work stops at its next clock check. Returns how much of that work has not finished. + public static int End(object run) + { + var ended = (Run)run; + global::System.Threading.Interlocked.Exchange(ref ended.Ended, 1); // a full fence: Spawn sees it, or End sees Spawned + if (global::System.Threading.Volatile.Read(ref ended.Spawned) != 0) + _orphans = 1; + if (global::System.Threading.Volatile.Read(ref ended.Async) != 0) + _asyncOrphans = 1; // what its async work started unwrapped carries the run on after that work: always look + return global::System.Threading.Volatile.Read(ref ended.Pending); + } + public static bool Init() { if (--Calls <= 0) @@ -89,7 +129,133 @@ public static bool Init() return true; } - public static bool Running() => _stopped == 0; + public static bool Running() => _stopped == 0 && (_orphans == 0 || (_live == 0 && _asyncOrphans == 0) || !Orphaned()); + + // The AsyncLocal is slow to read: only once asynchronous task work has ever run. + static Run Current() => _thread ?? (_asyncWork != 0 ? _async.Value : null); + + static bool Orphaned() + { + var run = Current(); + return run != null && global::System.Threading.Volatile.Read(ref run.Ended) != 0; + } + + /// The run new work belongs to: that of the work starting it, else the run in progress (none after it ended). + static Run Spawn(object work) + { + if (work == null) + return null; + var run = Current(); + if (run == null) + { + run = _current; + if (run == null || global::System.Threading.Volatile.Read(ref run.Ended) != 0) + return null; // a callback the run left behind: its work is its own + } + global::System.Threading.Interlocked.Exchange(ref run.Spawned, 1); + global::System.Threading.Interlocked.Increment(ref run.Pending); + if (global::System.Threading.Volatile.Read(ref run.Ended) != 0) + _orphans = 1; + return run; + } + + /// Work of a run starts: while none runs (_live), no check needs to look whose work it is. + static void Started() => global::System.Threading.Interlocked.Increment(ref _live); + + static void Done(Run run) + { + global::System.Threading.Interlocked.Decrement(ref _live); + global::System.Threading.Interlocked.Decrement(ref run.Pending); + } + + /// + /// Synchronous work of a run. A thread entry (swallow) swallows a stop: unhandled on a thread, it + /// would end the process; a task faults with it. + /// + static void Do(Run run, global::System.Action work, bool swallow) + { + Started(); + var thread = _thread; + var flowing = _asyncWork != 0 ? _async.Value : null; + _thread = run; + if (flowing != null) + _async.Value = null; // what the work starts without a wrapper (a timer, an async void continuation) is not the run's + try { work(); } + catch (global::System.Exception) when (swallow && !Running()) { } + finally + { + _thread = thread; + if (flowing != null) + _async.Value = flowing; + Done(run); + } + } + + /// Task work of a run: synchronous, or asynchronous (a task) carried into its awaits until the task ends. + static TResult Get(Run run, global::System.Func work) + { + if (!typeof(global::System.Threading.Tasks.Task).IsAssignableFrom(typeof(TResult))) + { + TResult value = default; + Do(run, () => value = work(), false); + return value; + } + _asyncWork = 1; + global::System.Threading.Interlocked.Exchange(ref run.Async, 1); + if (global::System.Threading.Volatile.Read(ref run.Ended) != 0) + _asyncOrphans = 1; + Started(); + var thread = _thread; + var flowing = _async.Value; + _thread = null; + _async.Value = run; + try + { + var result = work(); + if (result is global::System.Threading.Tasks.Task task) + task.ContinueWith(_ => Done(run), global::System.Threading.CancellationToken.None, + global::System.Threading.Tasks.TaskContinuationOptions.ExecuteSynchronously, global::System.Threading.Tasks.TaskScheduler.Default); + else + Done(run); + return result; + } + catch + { + Done(run); + throw; + } + finally + { + _thread = thread; + _async.Value = flowing; + } + } + + // Thread and thread-pool entries. + public static global::System.Threading.ThreadStart Worker(global::System.Threading.ThreadStart work) => + Spawn(work) is Run run ? () => Do(run, () => work(), true) : work; + + public static global::System.Threading.ParameterizedThreadStart Worker(global::System.Threading.ParameterizedThreadStart work) => + Spawn(work) is Run run ? state => Do(run, () => work(state), true) : work; + + public static global::System.Threading.WaitCallback Worker(global::System.Threading.WaitCallback work) => + Spawn(work) is Run run ? state => Do(run, () => work(state), true) : work; + + public static global::System.Action Worker(global::System.Action work) => + Spawn(work) is Run run ? state => Do(run, () => work(state), true) : work; + + // Task entries. + public static global::System.Action TaskWorker(global::System.Action work) => + Spawn(work) is Run run ? () => Do(run, work, false) : work; + + public static global::System.Action TaskWorker(global::System.Action work) => + Spawn(work) is Run run ? state => Do(run, () => work(state), false) : work; + + public static global::System.Func TaskWorker(global::System.Func work) => + Spawn(work) is Run run ? () => Get(run, work) : work; + + public static global::System.Func TaskWorker(global::System.Func work) => + Spawn(work) is Run run ? state => Get(run, () => work(state)) : work; public static unsafe void Call() { @@ -126,6 +292,8 @@ static void Check() { if (_stopped == 0) { + if (_orphans != 0 && (_live != 0 || _asyncOrphans != 0) && Orphaned()) + throw new global::System.TimeoutException(""The snippet's run has ended, so this thread or task it started was stopped: work a snippet starts must finish (join or await it) before the snippet returns.""); if (_budget <= 0 || unchecked(global::System.Environment.TickCount - _start) < _budget) return; _stopped = 1; @@ -148,6 +316,14 @@ static void Check() internal static void Arm(Type guard, int budgetMs, int maxStackBytes) => guard.GetMethod("Arm", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, new object[] { budgetMs, maxStackBytes }); + /// Starts a run: the threads and tasks its code starts belong to it (the token for ). + internal static object Begin(Type guard) => + guard.GetMethod("Begin", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, null)!; + + /// Ends the run : its threads and tasks stop at their next check. Returns how many had not finished. + internal static int End(Type guard, object run) => + (int)guard.GetMethod("End", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, new[] { run })!; + /// , 2 (stopped by the stack check) or 0 (not stopped). internal static int Stopped(Type guard) => (int)guard.GetMethod("Stopped", BindingFlags.Public | BindingFlags.Static)!.Invoke(null, null)!; diff --git a/fixtures/golden/csharp.json b/fixtures/golden/csharp.json index 4af4d6a..c0e04a6 100644 --- a/fixtures/golden/csharp.json +++ b/fixtures/golden/csharp.json @@ -66,6 +66,14 @@ "value": "9" } }, + { + "name": "using-declaration-first-is-a-statement", + "code": "using var w = new System.IO.StringWriter();\nw.Write(\"u\");\nw.ToString()", + "expect": { + "success": true, + "value": "u" + } + }, { "name": "request-usings", "code": "new StringBuilder(\"r\").ToString()", diff --git a/python/code_exec/__init__.py b/python/code_exec/__init__.py index 174521b..da95b45 100644 --- a/python/code_exec/__init__.py +++ b/python/code_exec/__init__.py @@ -8,9 +8,9 @@ text = code_exec.run_b64(payload_base64_json) # JSON in, JSON out: the entry point for C++ hosts """ -from .runner import (run, run_json, run_b64, set_value_converter, set_default_timeout_ms, set_max_recursion_limit, - SnippetTimeout, SNIPPET_FILE) +from .runner import (run, run_json, run_b64, set_value_converter, set_default_timeout_ms, set_max_timeout_ms, + set_max_recursion_limit, SnippetTimeout, SnippetThreadStopped, SNIPPET_FILE) -__all__ = ["run", "run_json", "run_b64", "set_value_converter", "set_default_timeout_ms", "set_max_recursion_limit", - "SnippetTimeout", "SNIPPET_FILE"] +__all__ = ["run", "run_json", "run_b64", "set_value_converter", "set_default_timeout_ms", "set_max_timeout_ms", + "set_max_recursion_limit", "SnippetTimeout", "SnippetThreadStopped", "SNIPPET_FILE"] __version__ = "0.3.0" diff --git a/python/code_exec/runner.py b/python/code_exec/runner.py index 4390a9d..16fe137 100644 --- a/python/code_exec/runner.py +++ b/python/code_exec/runner.py @@ -37,6 +37,7 @@ # Time budget (spec section 1, timeoutMs) of a request that sets none, in milliseconds; 0 = no bound. TIMEOUT_MS = 120000 _default_timeout_ms = TIMEOUT_MS +_max_timeout_ms = 0 # the highest budget a request may ask for (set_max_timeout_ms); 0 = no cap _REFIRE_SECONDS = 0.1 # a snippet that catches the stop is stopped again this often _POLL_SECONDS = 0.02 # how often the watchdog looks whether the snippet ended _MARGIN_SECONDS = 0.05 # a snippet ending this long before its deadline was never stopped: no need to wait for the watchdog @@ -81,6 +82,23 @@ def set_default_timeout_ms(timeout_ms): _default_timeout_ms = timeout_ms +def set_max_timeout_ms(timeout_ms): + """Host hook: the highest time budget a request may ask for (a longer ``timeout_ms`` / ``timeoutMs``, and a run that + would have no bound, get this one); 0 = no cap (default).""" + global _max_timeout_ms + if not _is_count(timeout_ms): + raise ValueError("timeout_ms must be an integer >= 0 (0 = no cap)") + _max_timeout_ms = timeout_ms + + +def _budget(timeout_ms): + """The request's budget, else the host default, at most the host's cap; 0 = no bound.""" + budget = timeout_ms or _default_timeout_ms + if _max_timeout_ms and (not budget or budget > _max_timeout_ms): + budget = _max_timeout_ms + return budget + + def set_max_recursion_limit(limit): """Host hook: the highest recursion limit a snippet may set with ``sys.setrecursionlimit`` on Python < 3.12 (default 3000, a limit the host set itself above it stays allowed); 0 = no cap. Python 3.12+ never cap.""" @@ -95,8 +113,9 @@ def run(code, include=(), include_root=None, imports=(), max_value_chars=MAX_VAL """Execute ``code`` and return the contract's response as a dict (never raises for snippet errors). ``include_outside_root``: the host accepts includes outside ``include_root`` (absolute paths, ``..``). - ``timeout_ms``: the snippet's time budget; 0 = the host default (:func:`set_default_timeout_ms`). A snippet still - running when it is spent is stopped with a ``Timeout`` error (spec section 4). + ``timeout_ms``: the snippet's time budget; 0 = the host default (:func:`set_default_timeout_ms`), at most the host's + cap (:func:`set_max_timeout_ms`). A snippet still running when it is spent is stopped with a ``Timeout`` error, and + threads it started are stopped when it ends (spec section 4). """ result = _empty() limits = (max_value_chars, max_log_chars, max_log_line_chars, max_error_chars) @@ -106,7 +125,7 @@ def run(code, include=(), include_root=None, imports=(), max_value_chars=MAX_VAL return _fail(result, "InvalidRequest", "timeoutMs must be an integer >= 0 (0 = the host's default)") logs = _Logs(max_log_chars, max_log_line_chars) try: - _execute(result, logs, code, include, include_root, imports, include_outside_root, timeout_ms or _default_timeout_ms) + _execute(result, logs, code, include, include_root, imports, include_outside_root, _budget(timeout_ms)) except BaseException as error: # the runner itself failed (a snippet wrecked the interpreter state): still answer result["success"], result["value"] = False, None _fail(result, "Runtime", "%s: %s (while running the snippet)" % (type(error).__name__, _message(error))) @@ -168,6 +187,8 @@ def _execute(result, logs, code, include, include_root, imports, include_outside prefixes = [_prefix(folder) for folder in folders] saved = _HostState(code, sources) watchdog = _Watchdog(timeout_ms) + agent_files = {SNIPPET_FILE} | {path for path, _ in files} + threads = _SnippetThreads(lambda filename: filename in agent_files or _under(filename, prefixes)) try: sys.path[:0] = folders if folders: @@ -180,6 +201,7 @@ def _execute(result, logs, code, include, include_root, imports, include_outside scope.setdefault(name.split(".")[0], __import__(name)) # like `import a.b`: binds the top package with redirect_stdout(out), redirect_stderr(err): saved.cap_recursion_limit(logs) + threads.start() watchdog.start() for compiled in file_code: exec(compiled, scope) @@ -204,6 +226,7 @@ def _execute(result, logs, code, include, include_root, imports, include_outside finally: out.flush() err.flush() + threads.stop(logs) saved.restore(prefixes) result["executeMs"] = _ms(executing) return result @@ -268,7 +291,7 @@ def start(self): self._thread_id = threading.get_ident() self._deadline = time.monotonic() + self._timeout_ms / 1000.0 self._armed = True - threading.Thread(target=self._watch, args=(stop,), name="code_exec watchdog", daemon=True).start() + _start_untracked(threading.Thread(target=self._watch, args=(stop,), name="code_exec watchdog", daemon=True)) def _watch(self, stop): try: @@ -299,6 +322,159 @@ def stop(self): pass +class SnippetThreadStopped(BaseException): + """Raised in a thread the snippet started, once the snippet has ended (spec section 4): threads may not outlive it.""" + + def __str__(self): + return "The snippet has ended, so this thread it started was stopped: join threads before the snippet returns." + + +# Runs whose snippet is running, and the threading.Thread.start the dispatcher stands in for while there is one. A host +# that replaced threading.Thread.start meanwhile keeps the dispatcher in its chain: it is then never installed again. +_thread_runs = [] +_saved_start = None +_dispatcher_kept = False + + +def _dispatch_start(thread, *args, **kwargs): + """``threading.Thread.start`` while snippets run: a thread started from a snippet's thread (or one of its threads) is + that run's.""" + for runs in list(_thread_runs): + if threading.get_ident() in runs.owners: + return runs.start_tracked(thread, args, kwargs) + return _saved_start(thread, *args, **kwargs) + + +def _start_untracked(thread): + """Starts one of the runner's own threads: never a snippet's.""" + (_saved_start if _dispatcher_kept or threading.Thread.start is _dispatch_start else threading.Thread.start)(thread) + + +class _Tracked: + __slots__ = ("thread", "starting", "done", "cancelled", "dismissed") + + def __init__(self, thread): + self.thread = thread + self.starting = self.done = self.cancelled = self.dismissed = False + + +class _SnippetThreads: + """The threads a snippet starts: ``threading.Thread.start`` called from the snippet's thread, or from one of those + threads, while the snippet runs. When it ends, a ``threading.Timer`` not fired yet is cancelled and every other one + still alive gets :class:`SnippetThreadStopped` (``PyThreadState_SetAsyncExc``) whenever the agent's own code + (``is_agent_file``) is the frame it runs, again every ``_REFIRE_SECONDS`` while that code is on its stack. A thread with + none of it on its stack (a pool worker waiting for work) is left alone from then on. The thread swallows the stop: + nothing is printed.""" + + def __init__(self, is_agent_file): + self._is_agent_file = is_agent_file + self._tracked = {} # id(thread) -> _Tracked; only ever added to, so the stopper thread can read it any time + self.owners = set() + self._active = False + + def start(self): + global _saved_start + if _SET_ASYNC_EXC is None: + return + self.owners.add(threading.get_ident()) + if not _thread_runs and not _dispatcher_kept: + _saved_start, threading.Thread.start = threading.Thread.start, _dispatch_start + _thread_runs.append(self) + self._active = True + + def start_tracked(self, thread, args, kwargs): + if id(thread) in self._tracked: # started twice: the second start raises as Python's own does + return _saved_start(thread, *args, **kwargs) + entry = self._tracked[id(thread)] = _Tracked(thread) + run, owners = thread.run, self.owners + + def tracked(): + ident = threading.get_ident() + owners.add(ident) + try: + try: + run() + finally: + while True: # a stop raised while leaving must not outlive the thread either + try: + entry.done = True + owners.discard(ident) + _async_exc(ident, None) + break + except SnippetThreadStopped: + pass + except SnippetThreadStopped: + pass + + thread.run = tracked + entry.starting = True + try: + return _saved_start(thread, *args, **kwargs) + finally: + entry.starting = False + + def stop(self, logs): + """Never raises: the host's state is restored after it, whatever the snippet's threads do.""" + global _dispatcher_kept + if not self._active: + return + self._active = False + try: + _thread_runs.remove(self) + if not _thread_runs and not _dispatcher_kept: + if threading.Thread.start is _dispatch_start: + threading.Thread.start = _saved_start + else: + _dispatcher_kept = True + self.owners.discard(threading.get_ident()) + entries = list(self._tracked.values()) + for entry in entries: + timer = entry.thread + if isinstance(timer, threading.Timer) and not timer.finished.is_set(): + threading.Timer.cancel(timer) + entry.cancelled = True + alive = [entry for entry in entries + if not entry.cancelled and (entry.starting or threading.Thread.is_alive(entry.thread))] + if alive: + logs.add("warning", "%d thread(s) the snippet started were still running when it ended: each is stopped " + "while it runs the snippet's code. Join threads before the snippet returns." % len(alive)) + if self._stop_running(): + _start_untracked(threading.Thread(target=self._watch, name="code_exec thread stopper", daemon=True)) + except BaseException: # a snippet's Thread subclass that raises: the rest of the run still ends cleanly + pass + + def _stop_running(self): + """Raises the stop in the tracked threads running the agent's code; returns whether any is still to watch.""" + frames = sys._current_frames() + watching = False + for entry in list(self._tracked.values()): + if entry.done or entry.cancelled or entry.dismissed: + continue + if entry.starting: # started from a thread of the snippet just as it ended + watching = True + continue + thread = entry.thread + top = frame = frames.get(thread.ident) + while frame is not None and not self._is_agent_file(frame.f_code.co_filename): + frame = frame.f_back + if frame is None or not threading.Thread.is_alive(thread): + entry.dismissed = True # ended, never started, or none of the agent's code left on its stack + continue + watching = True + if self._is_agent_file(top.f_code.co_filename) and not entry.done: + _async_exc(thread.ident, SnippetThreadStopped) + return watching + + def _watch(self): + try: + while True: + time.sleep(_REFIRE_SECONDS) + if not self._stop_running(): + break + except BaseException: # interpreter shutdown + pass + + def _async_exc(thread_id, exception): _SET_ASYNC_EXC(_THREAD_ID(thread_id), _PY_OBJECT(exception) if exception is not None else None) diff --git a/python/tests/test_code_exec.py b/python/tests/test_code_exec.py index 5a631be..f068ca4 100644 --- a/python/tests/test_code_exec.py +++ b/python/tests/test_code_exec.py @@ -406,6 +406,24 @@ def test_host_default_budget(self): with self.assertRaises(ValueError): code_exec.set_default_timeout_ms(-1) + def test_host_caps_the_budget(self): + code_exec.set_max_timeout_ms(200) + try: + for timeout_ms in (0, 60000): # the host default (120 s) and a longer request both get the cap + with self.subTest(timeout_ms): + result, elapsed = self.run_timed("while True: pass", timeout_ms=timeout_ms) + self.assertEqual("Timeout", result["errorKind"]) + self.assertIn("200 ms", result["error"]) + self.assertLess(elapsed, 5) + code_exec.set_default_timeout_ms(0) # no bound, but the cap still applies + self.assertIn("200 ms", code_exec.run("while True: pass")["error"]) + self.assertIn("100 ms", code_exec.run("while True: pass", timeout_ms=100)["error"]) # a shorter one stays + finally: + code_exec.set_max_timeout_ms(0) + code_exec.set_default_timeout_ms(runner.TIMEOUT_MS) + with self.assertRaises(ValueError): + code_exec.set_max_timeout_ms(-1) + def test_json_timeout_field(self): result = json.loads(code_exec.run_json(json.dumps({"code": "while True: pass", "timeoutMs": 200}))) self.assertEqual("Timeout", result["errorKind"]) @@ -433,6 +451,92 @@ def test_deep_recursion_is_a_runtime_error(self): self.assertIn("frame(s) left out", result["error"]) +@unittest.skipIf(runner._SET_ASYNC_EXC is None, "needs ctypes") +class SnippetThreadTests(unittest.TestCase): + """Spec section 4: threads a snippet started are stopped when it ends, while they run its code.""" + + SPIN = "import threading\ndef spin():\n n = 0\n while True:\n n += 1\n" + + def wait_gone(self, name, seconds=5): + deadline = time.monotonic() + seconds + while any(t.name == name for t in threading.enumerate()) and time.monotonic() < deadline: + time.sleep(0.01) + return not any(t.name == name for t in threading.enumerate()) + + def test_thread_left_running_is_stopped_and_reported(self): + original = threading.Thread.start + code = self.SPIN + "threading.Thread(target=spin, name='ce-spin', daemon=True).start()\n1" + result = code_exec.run(code) + self.assertTrue(result["success"], result) + self.assertTrue(self.wait_gone("ce-spin"), "the snippet's thread still runs") + self.assertEqual(["warning"], [entry["level"] for entry in result["logs"]]) + self.assertTrue(result["logs"][0]["message"].startswith("1 thread(s) the snippet started were still running")) + self.assertIs(original, threading.Thread.start) + + def test_thread_of_a_timed_out_snippet_is_stopped(self): + code = self.SPIN + "threading.Thread(target=spin, name='ce-spin-timeout', daemon=True).start()\nwhile True: pass" + result = code_exec.run(code, timeout_ms=300) + self.assertEqual("Timeout", result["errorKind"]) + self.assertTrue(self.wait_gone("ce-spin-timeout")) + + def test_thread_started_by_a_snippet_thread_and_one_that_catches_the_stop_once(self): + code = (self.SPIN + "def stubborn():\n try:\n spin()\n except BaseException:\n spin()\n" + "def parent():\n threading.Thread(target=spin, name='ce-child', daemon=True).start()\n stubborn()\n" + "t = threading.Thread(target=parent, name='ce-parent', daemon=True)\nt.start()\n" + "while not any(x.name == 'ce-child' for x in threading.enumerate()): pass") + result = code_exec.run(code, timeout_ms=5000) + self.assertTrue(result["success"], result) + self.assertTrue(self.wait_gone("ce-child")) + self.assertTrue(self.wait_gone("ce-parent")) + + def test_timer_not_fired_yet_is_cancelled(self): + code = "import sys, threading\nthreading.Timer(0.3, lambda: setattr(sys, '_code_exec_timer_fired', True)).start()" + result = code_exec.run(code) + self.assertTrue(result["success"], result) + self.assertEqual([], result["logs"]) # a cancelled timer is not counted as still running + time.sleep(0.6) + self.assertFalse(hasattr(sys, "_code_exec_timer_fired")) + + def test_a_thread_subclass_that_raises_does_not_keep_the_host_state_from_being_restored(self): + limit, path = sys.getrecursionlimit(), list(sys.path) + code = ("import sys, threading\nsys.setrecursionlimit(1234)\nclass T(threading.Timer):\n" + " def cancel(self):\n raise RuntimeError('boom')\n" + " def is_alive(self):\n raise RuntimeError('boom')\nT(5, print).start()\n1") + result = code_exec.run(code) + self.assertTrue(result["success"], result) + self.assertEqual((limit, path), (sys.getrecursionlimit(), sys.path)) + + def test_nested_run_on_a_snippet_thread_restores_thread_start(self): + original = threading.Thread.start + code = ("import threading, time, code_exec\ndef inner():\n code_exec.run('import time\\ntime.sleep(0.6)')\n" + "threading.Thread(target=inner, name='ce-nested', daemon=True).start()\ntime.sleep(0.2)\n1") + result = code_exec.run(code) + self.assertTrue(result["success"], result) + self.assertEqual(1, len(result["logs"]), result["logs"]) + self.assertTrue(result["logs"][0]["message"].startswith("1 thread(s)")) + self.assertTrue(self.wait_gone("ce-nested")) + self.assertIs(original, threading.Thread.start) + + def test_a_thread_started_twice_is_counted_once(self): + code = ("import threading, time\nt = threading.Thread(target=time.sleep, args=(0.5,))\nt.start()\n" + "try:\n t.start()\nexcept RuntimeError:\n pass\n1") + result = code_exec.run(code) + self.assertTrue(result["logs"][0]["message"].startswith("1 thread(s)"), result["logs"]) + + def test_joined_threads_and_host_threads_are_left_alone(self): + result = code_exec.run("import threading\nt = threading.Thread(target=lambda: None)\nt.start()\nt.join()\n1") + self.assertEqual([], result["logs"]) + # A thread the host starts from another thread while a snippet runs is the host's own. + def host(): + time.sleep(0.05) + threading.Thread(target=time.sleep, args=(0.5,), name="ce-host-child").start() + starter = threading.Thread(target=host) + starter.start() + result = code_exec.run("import time\ntime.sleep(0.3)\n1") + starter.join() + self.assertEqual([], result["logs"]) + + CAPPED = runner._CAPPED # Python 3.12+ raise RecursionError before the C stack runs out: no cap there diff --git a/spec/contract.md b/spec/contract.md index ffe090a..fca730b 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -13,7 +13,7 @@ One request/response shape for every runtime (C# and Python) and every host (Uni | `include` | string[] | Files or folders to compile/load with the snippet (§1.1). | | `entry` | `{ type, method }` | C# only. Full-source mode: the code is compiled verbatim and `type.method` is invoked. | | `parameters` | `{ name, type, value }[]` | C# only. Arguments passed to the snippet; `type` is a host type, `value` an already-deserialized object. | -| `timeoutMs` | int | The snippet's time budget. A snippet still running when it is spent stops with `Timeout`: C# through the execution guards (§3.6) and by no longer awaiting an asynchronous result, Python through a watchdog (§4). `0` or absent = the host's default (`120000` unless the host sets another; a host default of `0` = no bound). | +| `timeoutMs` | int | The snippet's time budget. A snippet still running when it is spent stops with `Timeout`: C# through the execution guards (§3.6) and by no longer awaiting an asynchronous result, Python through a watchdog (§4). `0` or absent = the host's default (`120000` unless the host sets another; a host default of `0` = no bound). A host may cap it (C# `CodeRunnerOptions.MaxTimeoutMs`, Python `code_exec.set_max_timeout_ms`; `0` = no cap, the default): a longer budget, or none, becomes the cap. | | `imports` | string[] | Python only. Modules the host binds into the snippet's namespace before it runs (`unreal`). | | `includeRoot` | string | Python JSON entry point only (`run_json` / `run_b64`): folder relative `include` paths resolve against. | | `includeOutsideRoot` | bool | Python JSON entry point only: the host accepts includes outside the include root (§1.1). Default `false`. | @@ -26,6 +26,11 @@ Every field but `code` is optional. For the four limits, `0` means no limit; a n `timeoutMs` is `InvalidRequest`. A request without them gets the defaults. A field of the wrong JSON type is `InvalidRequest`. +Hosts that serve agents should let the agent pass `timeoutMs` (a `timeout` argument of their execute tool) and keep +the budget short by default: an endless loop costs the agent the whole budget before it hears back. Recommended: a host +default of 30000 for interactive use, and a cap a few seconds below the host's own tool timeout, so the snippet's +`Timeout` answers before the host gives up. + ### 1.1. Includes - Relative paths resolve against the host's include root. An include must lie **inside** that root (the root itself included): a path that leaves it (`../x`, an absolute path elsewhere) is `InvalidRequest`, unless the host allows it @@ -58,6 +63,9 @@ Every field but `code` is optional. For the four limits, `0` means no limit; a n The snippet is parsed as C# **script** code and rewritten into one ordinary compilation unit: - `using` directives (including aliases and `using static`) go to the top, after the host defaults and `usings`; + `global using` goes before those. A using declaration or statement (`using var x = ...;`, `using T x = ...;`, + `await using ...`, `using (...)`) is a statement wherever it stands, also as the first line: only `using N;`, + `using static`, `using unsafe`, an alias (`using A = B;`) and `global using` are directives; - type declarations (class, struct, record, interface, enum, delegate, namespace) stay top-level; - members **without** modifiers live in the entry method's body, in source order, so they see the snippet's locals: `var x = 1;` is a local variable and `int Twice(int n) => n * 2;` a local function; @@ -145,6 +153,28 @@ the host turns them off (`CodeRunnerOptions.ExecutionGuards`): type, `Exception`, `SystemException` or the stop's own type) get a filter that lets it pass, so it reaches the runtime; `finally` blocks still run. That filter makes a few invalid programs compile (a later `catch` that an earlier catch-all would hide: `CS0160`). +- **Threads and tasks.** A delegate the agent's code hands to `new Thread(...)`, `ThreadPool.QueueUserWorkItem` / + `UnsafeQueueUserWorkItem`, `Task.Run`, `Task.Factory.StartNew` (any `TaskFactory.StartNew`) or `new Task(...)` belongs + to the run, and so do the threads and tasks it starts the same way. It runs under the same guards and, once the run + has ended (finished or timed out), stops at its next check with + `System.TimeoutException: The snippet's run has ended, so this thread or task it started was stopped: ...`. It passes + the agent's catch clauses like any stop. A thread or thread-pool entry swallows a stop (an exception left unhandled + on a thread would end the process); a task faults with it. Asynchronous task work (`Task.Run(async () => ...)`) + belongs to the run until its task ends, across its awaits. A stop inside such work while the snippet waited for it + (`Join`, `Wait`) ends the run too: `Timeout` for the budget, `Runtime` (`Recursion too deep`) for the stack. When + work of the run had not finished as it ended, the response gets one `warning` log entry: `N thread(s) or task(s) the + snippet started had not finished when it ended: they are stopped at their next loop iteration or call. Join or await + work the snippet starts.` (work created but never started counts too). Not covered, so it may outlive the run: timers + (`System.Threading.Timer`, `System.Timers.Timer`, engine timers) and other callbacks; an `async void` delegate's code + after its first `await` (`new Thread(async () => ...)`); work blocked in a wait or a host call (it stops once that + returns); threads started by native code or by host code the snippet calls; a start written through an alias of + `Thread` / `Task`, inside a query expression, or whose delegate type cannot be named (an anonymous type); a delegate + stored first and started later through another API; and work that a callback left behind starts after the run (it + is the callback's own). Caveat: a timer or other callback created inside asynchronous task work inherits the run + through its execution context; its code is stopped like the task's, and an exception unhandled in a timer callback + ends the process. Cost: a few hundred ns per start; once a run that started work has ended, every clock check also + looks whose work it runs while work of a run still executes (on a reused Mono assembly, for good once a run used + asynchronous task work): about 4 ns per call of a call-heavy loop then. - **After the run.** A run that finished lifts the time budget: callbacks it left behind (events, stored delegates) keep only the stack check. A timed-out run keeps its stop, so what still runs of it (an asynchronous continuation, a thread it started) stops at its next check; on a host that cannot unload (Mono), its assembly is not reused. @@ -174,7 +204,19 @@ the host turns them off (`CodeRunnerOptions.ExecutionGuards`): whose body begins with `try` or `with` (`while True: try: ...`): such a run hangs the interpreter there (3.11+ stop it). It needs `ctypes` (`PyThreadState_SetAsyncExc`); without it the budget is not enforced. The stop is disarmed before the runner returns: none reaches the host afterwards. - Threads the snippet started are not stopped. +- **Threads.** Threads the snippet starts (`threading.Thread.start`, `threading.Timer` included, called from the + snippet's thread or from one of those threads while it runs) may not outlive it. When it ends (finished or timed + out), a `threading.Timer` that has not fired is cancelled, and every other such thread still alive gets + `code_exec.SnippetThreadStopped` (a `BaseException`, the same mechanism as the time budget) whenever the snippet's or + an include's code is what it runs, checked every 100 ms while such code is on its stack. The thread ends quietly + (nothing is printed). One `warning` log entry counts the threads still running (cancelled timers aside): `N thread(s) + the snippet started were still running when it ended: each is stopped while it runs the snippet's code. Join threads + before the snippet returns.` A thread inside library or host code (`queue.get`) gets no stop until it returns to the + snippet's code; one with none of it left on its stack (a `concurrent.futures` worker waiting for work) is left alone + from then on; one that catches `BaseException` every time is not stopped. `threading.Thread.start` is replaced only + while a snippet runs. Not covered: `_thread.start_new_thread`, threads started by C code, and threads started by the + snippet's threads after it has ended. Threads that host code starts while the snippet calls it count as the + snippet's. Needs `ctypes`, like the time budget. - **Recursion** ends with Python's own `RecursionError` (`Runtime`) at the interpreter's recursion limit. Python 3.11 and older do not check the C stack: past a few thousand, a raised limit lets C-level recursion (`repr` / `json.dumps` of a deeply nested list) overflow it, which ends the host. So on Python < 3.12 a snippet's (and its includes') @@ -222,6 +264,9 @@ Lengths are in characters: UTF-16 code units in C#, code points in Python. A lim means the host's default (120000), a negative value is `InvalidRequest`. Full-source mode gets the request's `usings` and auto-import, and `CE0003` when names stay unresolved (§3.2). C#: expression-bodied lambdas that are delegates are guarded too (§3.6). Python < 3.12: a snippet's `sys.setrecursionlimit` is capped (default 3000) with a warning (§4). + Threads and tasks a snippet starts stop when its run ends, with a `warning` when some had not finished (C# §3.6, + Python §4). Hosts can cap `timeoutMs` (§1). C#: a using declaration (`using var x = ...;`) is a statement also as the + first line, and `global using` goes before the runtime's usings (§3.1). - **v0.3.1** — C#: `static readonly` fields of immutable types are not static state (§3.5). Python is unchanged. - **v0.3** — output limits (§6): request fields `maxValueChars`, `maxLogChars`, `maxLogLineChars`, `maxErrorChars`