From 248afcf625d9b19e9aedaebd9ffbfaf5e5374b02 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 00:31:41 -0700 Subject: [PATCH 1/5] fix: using declarations stay statements, snippet threads stop with their run, host cap for timeoutMs - C#: a top-level using declaration (`using var x = ...;`, `using T x = ...;`, `await using ...`) was parsed as a using directive in script code (CS1002/CS0825/CS0103). It is now a statement wherever it stands; `using X;`, `using static`, `using unsafe`, aliases and `global using` stay directives. `global using` goes before the runtime's usings (CS8915 before). - C#: delegates handed to new Thread / ThreadPool.QueueUserWorkItem / Task.Run / TaskFactory.StartNew / new Task are wrapped so the work belongs to the run and stops at its next guard check once the run has ended. A thread entry swallows the stop: before, a time stop on a snippet's thread was an unhandled exception that ended a CoreCLR host. A warning reports unfinished work; a budget spent in work the snippet waited for is a Timeout. - Python: threads the snippet starts are stopped when it ends (async exception while they run the agent's code), threading.Timer not fired yet is cancelled, a warning counts them. - Both: hosts can cap timeoutMs (CodeRunnerOptions.MaxTimeoutMs, code_exec.set_max_timeout_ms); spec recommends exposing a timeout argument with a ~30 s default. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- dotnet/CodeExec.Roslyn/src/GuardWriter.cs | 98 ++++++++++++++ dotnet/CodeExec.Roslyn/src/SnippetWriter.cs | 111 ++++++++++++++- dotnet/CodeExec.Tests/ChildProcess.cs | 19 ++- dotnet/CodeExec.Tests/GuardTests.cs | 100 ++++++++++++++ dotnet/CodeExec.Tests/RunnerTests.cs | 55 ++++++++ dotnet/CodeExec/CodeRunner.cs | 41 +++++- dotnet/CodeExec/Contracts.cs | 3 +- dotnet/CodeExec/ExecutionGuard.cs | 141 +++++++++++++++++++- fixtures/golden/csharp.json | 8 ++ python/code_exec/__init__.py | 8 +- python/code_exec/runner.py | 132 +++++++++++++++++- python/tests/test_code_exec.py | 77 +++++++++++ spec/contract.md | 39 +++++- 14 files changed, 810 insertions(+), 32 deletions(-) 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..f9e1528 100644 --- a/dotnet/CodeExec.Roslyn/src/GuardWriter.cs +++ b/dotnet/CodeExec.Roslyn/src/GuardWriter.cs @@ -208,9 +208,107 @@ 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 or task start becomes Worker((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)) + return; + foreach (var outer in call.Ancestors()) + { + if (outer is QueryExpressionSyntax || (outer is AnonymousFunctionExpressionSyntax function && !semantics.IsDelegate(function))) + return; + } + 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) || !CanBeNamed(type)) + continue; + var cast = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + edits.Add(new Edit(expression.SpanStart, ExecutionGuard.Worker + "((" + cast + ")(", expression.SpanStart, edits.Count)); + edits.Add(new Edit(expression.Span.End, "))", expression.Span.End, edits.Count, closer: true)); + } + } + + static bool StartsWork(IMethodSymbol method) + { + switch (method.ContainingType?.OriginalDefinition.ToDisplayString()) + { + case "System.Threading.Thread": + case "System.Threading.Tasks.Task": + return method.MethodKind == MethodKind.Constructor; + case "System.Threading.Tasks.Task": + return method.MethodKind == MethodKind.Constructor || method.Name == "Run"; + case "System.Threading.ThreadPool": + return method.Name == "QueueUserWorkItem" || method.Name == "UnsafeQueueUserWorkItem"; + case "System.Threading.Tasks.TaskFactory": + case "System.Threading.Tasks.TaskFactory": + return method.Name == "StartNew"; + default: + return false; + } + } + + /// The delegate types has an overload for. + static bool IsWorkDelegate(INamedTypeSymbol type) + { + switch (type.OriginalDefinition.ToDisplayString()) + { + case "System.Threading.ThreadStart": + case "System.Threading.ParameterizedThreadStart": + case "System.Threading.WaitCallback": + case "System.Action": + case "System.Action": + case "System.Func": + case "System.Func": + return true; + 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..49a01e6 100644 --- a/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs +++ b/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs @@ -42,7 +42,21 @@ public Output(string source, TextSpan? returnedExpression, bool hasOtherReturns) public static Output Write(string code, IReadOnlyList usings, IReadOnlyList parameters, bool returnLastExpression) { 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 = UsingDeclarations(code, scriptOptions); + 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 @@ -66,19 +80,21 @@ public static Output Write(string code, IReadOnlyList usings, IReadOnlyL } 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 +142,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 +174,87 @@ 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. + static bool IsDeclaration(List tokens, int at) + { + var next = tokens[at].Kind(); + if (next == SyntaxKind.OpenParenToken || next == SyntaxKind.StaticKeyword || next == SyntaxKind.UnsafeKeyword) + return false; + if (next == SyntaxKind.IdentifierToken && at + 1 < tokens.Count && tokens[at + 1].IsKind(SyntaxKind.EqualsToken)) + return false; // `using A = B;` + 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 true; + case SyntaxKind.SemicolonToken: + case SyntaxKind.OpenBraceToken: + case SyntaxKind.CloseBraceToken: + case SyntaxKind.EndOfFileToken: + return false; + } + } + return false; + } + + 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..db885d9 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,76 @@ 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));")] + // 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")] + 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.StartsWith("System.TimeoutException: The snippet ran longer than its time budget of 300 ms (timeoutMs)", 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")] + // 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 +332,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..f4ca5ac 100644 --- a/dotnet/CodeExec.Tests/RunnerTests.cs +++ b/dotnet/CodeExec.Tests/RunnerTests.cs @@ -282,7 +282,62 @@ 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)); + } + + [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..36bd5b4 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) { @@ -665,18 +677,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 +702,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 +768,11 @@ public ExecuteResult Finish() object? value = null; try { + // The budget ran out in a thread or task the snippet started while the snippet itself waited (Join, + // Wait): that work was stopped, so the run is no success either. + if (_errorKind == ErrorKind.None && _guard != null && ExecutionGuard.Stopped(_guard) == 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."); 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..03e9f75 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -48,6 +48,14 @@ 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"; + internal const int StoppedByTime = 1; /// @@ -59,14 +67,26 @@ 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 in an AsyncLocal (it + /// flows into the work's awaits and the tasks it starts in turn); once a run with such 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; static long _base; + static Run _current; + static readonly global::System.Threading.AsyncLocal _worker = new global::System.Threading.AsyncLocal(); + + /// One run: whether it has ended, and how much of the work it started has not finished. + public sealed class Run + { + public volatile bool Ended; + public int Pending; + } public static unsafe void Arm(int budgetMs, int maxStackBytes) { @@ -82,6 +102,19 @@ 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; + ended.Ended = true; + var pending = global::System.Threading.Volatile.Read(ref ended.Pending); + if (pending > 0) + _orphans = 1; + return pending; + } + public static bool Init() { if (--Calls <= 0) @@ -89,7 +122,99 @@ public static bool Init() return true; } - public static bool Running() => _stopped == 0; + public static bool Running() => _stopped == 0 && (_orphans == 0 || !Orphaned()); + + static bool Orphaned() + { + var run = _worker.Value; + return run != null && run.Ended; + } + + /// The run new work belongs to: that of the work starting it, else the run in progress (none after it ended). + static Run Spawn() + { + var run = _worker.Value; + if (run == null) + { + run = _current; + if (run == null || run.Ended) + return null; // a callback the run left behind: its work is its own + } + global::System.Threading.Interlocked.Increment(ref run.Pending); + if (run.Ended) + _orphans = 1; + return run; + } + + static Run Enter(Run run) + { + var outer = _worker.Value; + _worker.Value = run; + return outer; + } + + static void Exit(Run run, Run outer) + { + _worker.Value = outer; + global::System.Threading.Interlocked.Decrement(ref run.Pending); + } + + public static global::System.Threading.ThreadStart Worker(global::System.Threading.ThreadStart work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return () => { var outer = Enter(run); try { work(); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; + } + + public static global::System.Threading.ParameterizedThreadStart Worker(global::System.Threading.ParameterizedThreadStart work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return state => { var outer = Enter(run); try { work(state); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; + } + + public static global::System.Threading.WaitCallback Worker(global::System.Threading.WaitCallback work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return state => { var outer = Enter(run); try { work(state); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; + } + + public static global::System.Action Worker(global::System.Action work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return () => { var outer = Enter(run); try { work(); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; + } + + public static global::System.Action Worker(global::System.Action work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return state => { var outer = Enter(run); try { work(state); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; + } + + // A value-returning entry (a task's) keeps the stop: the task faults with it, which ends nothing. + public static global::System.Func Worker(global::System.Func work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return () => { var outer = Enter(run); try { return work(); } finally { Exit(run, outer); } }; + } + + public static global::System.Func Worker(global::System.Func work) + { + var run = work == null ? null : Spawn(); + if (run == null) + return work; + return state => { var outer = Enter(run); try { return work(state); } finally { Exit(run, outer); } }; + } public static unsafe void Call() { @@ -126,6 +251,8 @@ static void Check() { if (_stopped == 0) { + if (_orphans != 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 +275,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..60d4212 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: @@ -181,6 +202,7 @@ def _execute(result, logs, code, include, include_root, imports, include_outside with redirect_stdout(out), redirect_stderr(err): saved.cap_recursion_limit(logs) watchdog.start() + threads.start() for compiled in file_code: exec(compiled, scope) exec(body, scope) @@ -202,6 +224,7 @@ def _execute(result, logs, code, include, include_root, imports, include_outside _make_room(saved) watchdog.stop() finally: + threads.stop(logs) out.flush() err.flush() saved.restore(prefixes) @@ -299,6 +322,109 @@ 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." + + +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``) while it runs the agent's own code + (``is_agent_file`` of a frame on its stack), again every ``_REFIRE_SECONDS`` while it does. A thread idle in library + code (a pool worker waiting for work) is left alone. The thread swallows the stop: nothing is printed.""" + + def __init__(self, is_agent_file): + self._is_agent_file = is_agent_file + self._threads = [] + self._owners = set() + self._starting = set() # tracked threads whose start() has not returned yet + self._start = None + + def start(self): + if _SET_ASYNC_EXC is None: + return + self._owners.add(threading.get_ident()) + original = self._start = threading.Thread.start + track = self._track + + def start(thread, *args, **kwargs): + if threading.get_ident() not in self._owners: + return original(thread, *args, **kwargs) + track(thread) + self._starting.add(thread) + try: + return original(thread, *args, **kwargs) + finally: + self._starting.discard(thread) + + threading.Thread.start = start + + def _track(self, thread): + run, owners = thread.run, self._owners + + def tracked(): + ident = threading.get_ident() + owners.add(ident) + try: + try: + run() + except SnippetThreadStopped: + pass + except SnippetThreadStopped: # delivered while the first handler ran + pass + finally: + owners.discard(ident) + _async_exc(ident, None) # one raised but not delivered yet must not outlive the thread either + + thread.run = tracked + self._threads.append(thread) + + def stop(self, logs): + if self._start is None: + return + threading.Thread.start, self._start = self._start, None + self._owners.discard(threading.get_ident()) + for thread in self._threads: + if isinstance(thread, threading.Timer): + thread.cancel() + alive = [thread for thread in self._threads if thread.is_alive() or thread in self._starting] + if not alive: + return + 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)) + self._threads = alive + self._stop_running() + if self._threads: + threading.Thread(target=self._watch, name="code_exec thread stopper", daemon=True).start() + + def _stop_running(self): + """Raises the stop in every tracked thread that runs the agent's code; forgets threads that ended or idle in + library code.""" + frames = sys._current_frames() + running = [] + for thread in self._threads: + if thread in self._starting: # started from a thread of the snippet just as it ended + running.append(thread) + continue + frame = frames.get(thread.ident) + if frame is None or not thread.is_alive(): + continue + while frame is not None and not self._is_agent_file(frame.f_code.co_filename): + frame = frame.f_back + if frame is not None: + _async_exc(thread.ident, SnippetThreadStopped) + running.append(thread) + self._threads = running + + def _watch(self): + while self._threads: + time.sleep(_REFIRE_SECONDS) + self._stop_running() + + 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..d735201 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,65 @@ 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) + time.sleep(0.6) + self.assertFalse(hasattr(sys, "_code_exec_timer_fired")) + + 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..87e34c4 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,20 @@ 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: so does what it starts in turn and its asynchronous continuations. 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. A budget spent inside such work while the snippet waited + for it (`Join`, `Wait`) makes the run a `Timeout` too. 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.` Not covered: timers + (`System.Threading.Timer`, `System.Timers.Timer`, engine timers) and other callbacks, which may outlive the run on + purpose; 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 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). - **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 +196,17 @@ 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) while it runs the + snippet's or an include's code, again every 100 ms while it does. The thread ends quietly (nothing is printed). One + `warning` log entry counts them: `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 that waits in library or host code + (a `concurrent.futures` worker waiting for work, `time.sleep`) is left alone until it runs the snippet's code again; + one that catches `BaseException` every time is not stopped. Not covered: `_thread.start_new_thread`, threads started + by C code or by host code the snippet calls, and threads started by the snippet's threads after it has ended. 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 +254,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` From 9ddee5e14174fcc241c34b296b7295987c14a724 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 00:34:25 -0700 Subject: [PATCH 2/5] simplify: one entry helper per worker kind in the guard; hide the guard's nested closures from stack traces Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec.Tests/GuardTests.cs | 10 ++++ dotnet/CodeExec/CodeRunner.cs | 10 +++- dotnet/CodeExec/ExecutionGuard.cs | 84 +++++++++++------------------ 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index db885d9..cd252c4 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -255,6 +255,16 @@ public void BudgetSpentInWorkTheSnippetWaitsForIsATimeout(string code) Assert.StartsWith("System.TimeoutException: The snippet ran longer than its time budget of 300 ms (timeoutMs)", 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() { diff --git a/dotnet/CodeExec/CodeRunner.cs b/dotnet/CodeExec/CodeRunner.cs index 36bd5b4..54abb44 100644 --- a/dotnet/CodeExec/CodeRunner.cs +++ b/dotnet/CodeExec/CodeRunner.cs @@ -501,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; @@ -515,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(); diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index 03e9f75..6f6fdd5 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -131,8 +131,10 @@ static bool Orphaned() } /// The run new work belongs to: that of the work starting it, else the run in progress (none after it ended). - static Run Spawn() + static Run Spawn(object work) { + if (work == null) + return null; var run = _worker.Value; if (run == null) { @@ -146,11 +148,23 @@ static Run Spawn() return run; } - static Run Enter(Run run) + /// Runs work as part of its run. A void entry (a thread's) swallows a stop: unhandled there, it would end the process. + static void Do(Run run, global::System.Action work) { var outer = _worker.Value; _worker.Value = run; - return outer; + try { work(); } + catch (global::System.Exception) when (!Running()) { } + finally { Exit(run, outer); } + } + + /// A value-returning entry (a task's) keeps the stop: the task faults with it, which ends nothing. + static TResult Get(Run run, global::System.Func work) + { + var outer = _worker.Value; + _worker.Value = run; + try { return work(); } + finally { Exit(run, outer); } } static void Exit(Run run, Run outer) @@ -159,62 +173,26 @@ static void Exit(Run run, Run outer) global::System.Threading.Interlocked.Decrement(ref run.Pending); } - public static global::System.Threading.ThreadStart Worker(global::System.Threading.ThreadStart work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return () => { var outer = Enter(run); try { work(); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; - } + public static global::System.Threading.ThreadStart Worker(global::System.Threading.ThreadStart work) => + Spawn(work) is Run run ? () => Do(run, () => work()) : work; - public static global::System.Threading.ParameterizedThreadStart Worker(global::System.Threading.ParameterizedThreadStart work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return state => { var outer = Enter(run); try { work(state); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; - } + public static global::System.Threading.ParameterizedThreadStart Worker(global::System.Threading.ParameterizedThreadStart work) => + Spawn(work) is Run run ? state => Do(run, () => work(state)) : work; - public static global::System.Threading.WaitCallback Worker(global::System.Threading.WaitCallback work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return state => { var outer = Enter(run); try { work(state); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; - } + public static global::System.Threading.WaitCallback Worker(global::System.Threading.WaitCallback work) => + Spawn(work) is Run run ? state => Do(run, () => work(state)) : work; - public static global::System.Action Worker(global::System.Action work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return () => { var outer = Enter(run); try { work(); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; - } + public static global::System.Action Worker(global::System.Action work) => + Spawn(work) is Run run ? () => Do(run, work) : work; - public static global::System.Action Worker(global::System.Action work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return state => { var outer = Enter(run); try { work(state); } catch (global::System.Exception) when (!Running()) { } finally { Exit(run, outer); } }; - } + public static global::System.Action Worker(global::System.Action work) => + Spawn(work) is Run run ? state => Do(run, () => work(state)) : work; - // A value-returning entry (a task's) keeps the stop: the task faults with it, which ends nothing. - public static global::System.Func Worker(global::System.Func work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return () => { var outer = Enter(run); try { return work(); } finally { Exit(run, outer); } }; - } + public static global::System.Func Worker(global::System.Func work) => + Spawn(work) is Run run ? () => Get(run, work) : work; - public static global::System.Func Worker(global::System.Func work) - { - var run = work == null ? null : Spawn(); - if (run == null) - return work; - return state => { var outer = Enter(run); try { return work(state); } finally { Exit(run, outer); } }; - } + public static global::System.Func Worker(global::System.Func work) => + Spawn(work) is Run run ? state => Get(run, () => work(state)) : work; public static unsafe void Call() { From 4f833a05e00fb528362e047c2f5b9f4b78f2c6fc Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 00:59:41 -0700 Subject: [PATCH 3/5] code review: fix worker lifetime, crash and restore findings C# - Work carries its run in a thread-static while synchronous and in an AsyncLocal only for asynchronous task work, so timers and async void continuations started from a worker no longer inherit it (an orphan stop there ended the process). - Asynchronous task work stays pending until its task ends; End turns on orphan checks whenever the run started work (fenced against Spawn), so a continuation after the run ended is stopped and reported. - Thread entries (Worker) swallow stops, task entries (TaskWorker) fault: a void task no longer completes after a stop. - A stack stop in a thread the snippet waited for is a Runtime error, not success. - Using declarations: a keyword after `using` or a type with two names in a row is no declaration; a detected declaration that does not become a statement keeps its `using` (re-parse), so invalid code keeps its own error. Python - One Thread.start dispatcher installed while any snippet runs (nested runs on snippet threads restore correctly); the runner's own threads start untracked; stop() never raises and runs after flushing logs. - A stop is raised only while the agent's code is the running frame, never once the thread is leaving; cancelled timers are not counted; a thread started twice is tracked once. Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec.Roslyn/src/GuardWriter.cs | 40 +++-- dotnet/CodeExec.Roslyn/src/SnippetWriter.cs | 32 +++- dotnet/CodeExec.Tests/GuardTests.cs | 20 ++- dotnet/CodeExec.Tests/RunnerTests.cs | 11 ++ dotnet/CodeExec/CodeRunner.cs | 10 +- dotnet/CodeExec/ExecutionGuard.cs | 133 ++++++++++----- python/code_exec/runner.py | 178 +++++++++++++------- python/tests/test_code_exec.py | 27 +++ spec/contract.md | 44 +++-- 9 files changed, 343 insertions(+), 152 deletions(-) diff --git a/dotnet/CodeExec.Roslyn/src/GuardWriter.cs b/dotnet/CodeExec.Roslyn/src/GuardWriter.cs index f9e1528..9b572a1 100644 --- a/dotnet/CodeExec.Roslyn/src/GuardWriter.cs +++ b/dotnet/CodeExec.Roslyn/src/GuardWriter.cs @@ -235,63 +235,69 @@ static void Collect(SyntaxNode node, List edits, Semantics? semantics) }; /// - /// A delegate handed to a thread or task start becomes Worker((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. + /// 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)) + 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) || !CanBeNamed(type)) + || !IsWorkDelegate(type, task) || !CanBeNamed(type)) continue; var cast = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - edits.Add(new Edit(expression.SpanStart, ExecutionGuard.Worker + "((" + cast + ")(", expression.SpanStart, edits.Count)); + 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)); } } - static bool StartsWork(IMethodSymbol method) + /// 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; + return method.MethodKind == MethodKind.Constructor ? true : (bool?)null; case "System.Threading.Tasks.Task": - return method.MethodKind == MethodKind.Constructor || method.Name == "Run"; - case "System.Threading.ThreadPool": - return method.Name == "QueueUserWorkItem" || method.Name == "UnsafeQueueUserWorkItem"; + 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"; + return method.Name == "StartNew" ? true : (bool?)null; default: - return false; + return null; } } - /// The delegate types has an overload for. - static bool IsWorkDelegate(INamedTypeSymbol type) + /// 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.Action": case "System.Func": case "System.Func": - return true; + return task; default: return false; } diff --git a/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs b/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs index 49a01e6..4c49f4f 100644 --- a/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs +++ b/dotnet/CodeExec.Roslyn/src/SnippetWriter.cs @@ -39,12 +39,15 @@ 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); // 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 = UsingDeclarations(code, scriptOptions); + var declarations = findDeclarations ? UsingDeclarations(code, scriptOptions) : new Dictionary(); var parsed = code; if (declarations.Count > 0) { @@ -78,6 +81,9 @@ 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, global usings every other one @@ -221,13 +227,16 @@ static Dictionary UsingDeclarations(string code, CSharpPa } /// 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 || next == SyntaxKind.StaticKeyword || next == SyntaxKind.UnsafeKeyword) + if (next == SyntaxKind.OpenParenToken || (SyntaxFacts.IsKeywordKind(next) && !SyntaxFacts.IsPredefinedType(next))) return false; - if (next == SyntaxKind.IdentifierToken && at + 1 < tokens.Count && tokens[at + 1].IsKind(SyntaxKind.EqualsToken)) - return false; // `using A = B;` var depth = 0; for (var i = at; i < tokens.Count; i++) { @@ -242,7 +251,7 @@ static bool IsDeclaration(List tokens, int at) depth--; break; case SyntaxKind.EqualsToken when depth == 0: - return true; + return i - 1 > at && tokens[i - 1].IsKind(SyntaxKind.IdentifierToken) && !HasAdjacentNames(tokens, at, i - 1); case SyntaxKind.SemicolonToken: case SyntaxKind.OpenBraceToken: case SyntaxKind.CloseBraceToken: @@ -253,6 +262,17 @@ static bool IsDeclaration(List tokens, int at) 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); /// diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index cd252c4..10ad1c6 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -233,6 +233,8 @@ public void FinishedRunLiftsTheBudgetForCallbacksItLeftBehind() [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));")] // 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. @@ -248,11 +250,22 @@ public void WorkTheSnippetStartedStopsWhenItsRunEnds(string code) [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.StartsWith("System.TimeoutException: The snippet ran longer than its time budget of 300 ms (timeoutMs)", 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] @@ -279,6 +292,11 @@ public void UnfinishedWorkIsReportedAsAWarning() [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) diff --git a/dotnet/CodeExec.Tests/RunnerTests.cs b/dotnet/CodeExec.Tests/RunnerTests.cs index f4ca5ac..777f8f6 100644 --- a/dotnet/CodeExec.Tests/RunnerTests.cs +++ b/dotnet/CodeExec.Tests/RunnerTests.cs @@ -317,6 +317,17 @@ public void UsingDeclarationsKeepTheAgentsCoordinates(string code, int line, int 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() { diff --git a/dotnet/CodeExec/CodeRunner.cs b/dotnet/CodeExec/CodeRunner.cs index 54abb44..b7b8dee 100644 --- a/dotnet/CodeExec/CodeRunner.cs +++ b/dotnet/CodeExec/CodeRunner.cs @@ -776,11 +776,15 @@ public ExecuteResult Finish() object? value = null; try { - // The budget ran out in a thread or task the snippet started while the snippet itself waited (Join, - // Wait): that work was stopped, so the run is no success either. - if (_errorKind == ErrorKind.None && _guard != null && ExecutionGuard.Stopped(_guard) == ExecutionGuard.StoppedByTime) + // 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/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index 6f6fdd5..7896e6d 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -56,6 +56,9 @@ public static class ExecutionGuard /// 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; /// @@ -67,10 +70,11 @@ 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. Work handed to a thread or task start () carries its run in an AsyncLocal (it - /// flows into the work's awaits and the tasks it starts in turn); once a run with such 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. + /// 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 @@ -79,13 +83,15 @@ internal static class __CodeExecGuard static int _start, _last, _interval, _budget, _stopped, _maxStack, _baseThread, _orphans; static long _base; static Run _current; - static readonly global::System.Threading.AsyncLocal _worker = new global::System.Threading.AsyncLocal(); + // 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, and how much of the work it started has not finished. + /// One run: whether it has ended, whether it started work, and how much of that work has not finished. public sealed class Run { - public volatile bool Ended; - public int Pending; + public int Ended, Spawned, Pending; } public static unsafe void Arm(int budgetMs, int maxStackBytes) @@ -108,11 +114,10 @@ public static unsafe void Arm(int budgetMs, int maxStackBytes) public static int End(object run) { var ended = (Run)run; - ended.Ended = true; - var pending = global::System.Threading.Volatile.Read(ref ended.Pending); - if (pending > 0) + 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; - return pending; + return global::System.Threading.Volatile.Read(ref ended.Pending); } public static bool Init() @@ -124,10 +129,12 @@ public static bool Init() public static bool Running() => _stopped == 0 && (_orphans == 0 || !Orphaned()); + static Run Current() => _thread ?? _async.Value; + static bool Orphaned() { - var run = _worker.Value; - return run != null && run.Ended; + 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). @@ -135,63 +142,103 @@ static Run Spawn(object work) { if (work == null) return null; - var run = _worker.Value; + var run = Current(); if (run == null) { run = _current; - if (run == null || run.Ended) + 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 (run.Ended) + if (global::System.Threading.Volatile.Read(ref run.Ended) != 0) _orphans = 1; return run; } - /// Runs work as part of its run. A void entry (a thread's) swallows a stop: unhandled there, it would end the process. - static void Do(Run run, global::System.Action work) + static void Done(Run run) => 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) { - var outer = _worker.Value; - _worker.Value = run; + var thread = _thread; + var flowing = _async.Value; + _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 (!Running()) { } - finally { Exit(run, outer); } + catch (global::System.Exception) when (swallow && !Running()) { } + finally + { + _thread = thread; + if (flowing != null) + _async.Value = flowing; + Done(run); + } } - /// A value-returning entry (a task's) keeps the stop: the task faults with it, which ends nothing. + /// 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) { - var outer = _worker.Value; - _worker.Value = run; - try { return work(); } - finally { Exit(run, outer); } - } - - static void Exit(Run run, Run outer) - { - _worker.Value = outer; - global::System.Threading.Interlocked.Decrement(ref run.Pending); + if (!typeof(global::System.Threading.Tasks.Task).IsAssignableFrom(typeof(TResult))) + { + TResult value = default; + Do(run, () => value = work(), false); + return value; + } + 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()) : 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)) : 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)) : work; - - public static global::System.Action Worker(global::System.Action work) => - Spawn(work) is Run run ? () => Do(run, work) : 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)) : 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 Worker(global::System.Func 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 Worker(global::System.Func 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() diff --git a/python/code_exec/runner.py b/python/code_exec/runner.py index 60d4212..16fe137 100644 --- a/python/code_exec/runner.py +++ b/python/code_exec/runner.py @@ -201,8 +201,8 @@ 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) - watchdog.start() threads.start() + watchdog.start() for compiled in file_code: exec(compiled, scope) exec(body, scope) @@ -224,9 +224,9 @@ def _execute(result, logs, code, include, include_root, imports, include_outside _make_room(saved) watchdog.stop() finally: - threads.stop(logs) out.flush() err.flush() + threads.stop(logs) saved.restore(prefixes) result["executeMs"] = _ms(executing) return result @@ -291,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: @@ -329,41 +329,64 @@ 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``) while it runs the agent's own code - (``is_agent_file`` of a frame on its stack), again every ``_REFIRE_SECONDS`` while it does. A thread idle in library - code (a pool worker waiting for work) is left alone. The thread swallows the stop: nothing is printed.""" + 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._threads = [] - self._owners = set() - self._starting = set() # tracked threads whose start() has not returned yet - self._start = None + 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()) - original = self._start = threading.Thread.start - track = self._track - - def start(thread, *args, **kwargs): - if threading.get_ident() not in self._owners: - return original(thread, *args, **kwargs) - track(thread) - self._starting.add(thread) - try: - return original(thread, *args, **kwargs) - finally: - self._starting.discard(thread) - - threading.Thread.start = start - - def _track(self, thread): - run, owners = thread.run, self._owners + 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() @@ -371,58 +394,85 @@ def tracked(): try: try: run() - except SnippetThreadStopped: - pass - except SnippetThreadStopped: # delivered while the first handler ran + 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 - finally: - owners.discard(ident) - _async_exc(ident, None) # one raised but not delivered yet must not outlive the thread either thread.run = tracked - self._threads.append(thread) + entry.starting = True + try: + return _saved_start(thread, *args, **kwargs) + finally: + entry.starting = False def stop(self, logs): - if self._start is None: + """Never raises: the host's state is restored after it, whatever the snippet's threads do.""" + global _dispatcher_kept + if not self._active: return - threading.Thread.start, self._start = self._start, None - self._owners.discard(threading.get_ident()) - for thread in self._threads: - if isinstance(thread, threading.Timer): - thread.cancel() - alive = [thread for thread in self._threads if thread.is_alive() or thread in self._starting] - if not alive: - return - 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)) - self._threads = alive - self._stop_running() - if self._threads: - threading.Thread(target=self._watch, name="code_exec thread stopper", daemon=True).start() + 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 every tracked thread that runs the agent's code; forgets threads that ended or idle in - library code.""" + """Raises the stop in the tracked threads running the agent's code; returns whether any is still to watch.""" frames = sys._current_frames() - running = [] - for thread in self._threads: - if thread in self._starting: # started from a thread of the snippet just as it ended - running.append(thread) + watching = False + for entry in list(self._tracked.values()): + if entry.done or entry.cancelled or entry.dismissed: continue - frame = frames.get(thread.ident) - if frame is None or not thread.is_alive(): + 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 not None: + 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) - running.append(thread) - self._threads = running + return watching def _watch(self): - while self._threads: - time.sleep(_REFIRE_SECONDS) - self._stop_running() + try: + while True: + time.sleep(_REFIRE_SECONDS) + if not self._stop_running(): + break + except BaseException: # interpreter shutdown + pass def _async_exc(thread_id, exception): diff --git a/python/tests/test_code_exec.py b/python/tests/test_code_exec.py index d735201..f068ca4 100644 --- a/python/tests/test_code_exec.py +++ b/python/tests/test_code_exec.py @@ -493,9 +493,36 @@ 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"]) diff --git a/spec/contract.md b/spec/contract.md index 87e34c4..23a842e 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -155,18 +155,24 @@ the host turns them off (`CodeRunnerOptions.ExecutionGuards`): 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: so does what it starts in turn and its asynchronous continuations. It runs under the same guards and, - once the run has ended (finished or timed out), stops at its next check with + 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. A budget spent inside such work while the snippet waited - for it (`Join`, `Wait`) makes the run a `Timeout` too. 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.` Not covered: timers - (`System.Threading.Timer`, `System.Timers.Timer`, engine timers) and other callbacks, which may outlive the run on - purpose; 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 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). + 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. - **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. @@ -199,14 +205,16 @@ the host turns them off (`CodeRunnerOptions.ExecutionGuards`): - **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) while it runs the - snippet's or an include's code, again every 100 ms while it does. The thread ends quietly (nothing is printed). One - `warning` log entry counts them: `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 that waits in library or host code - (a `concurrent.futures` worker waiting for work, `time.sleep`) is left alone until it runs the snippet's code again; - one that catches `BaseException` every time is not stopped. Not covered: `_thread.start_new_thread`, threads started - by C code or by host code the snippet calls, and threads started by the snippet's threads after it has ended. Needs - `ctypes`, like the time budget. + `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') From 3d6659b7941d73bca69b04057de314a28436b494 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 01:14:00 -0700 Subject: [PATCH 4/5] perf: worker-ownership checks only while work of a run is live, AsyncLocal only once async task work ran After a run that started work, a reused (Mono) assembly paid a thread-static or AsyncLocal read on every clock check: fib(27) went from 1.5 ms to 2.8 ms. Now the check is skipped while no work of any run is executing. Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec/ExecutionGuard.cs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index 7896e6d..efab1b1 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -80,7 +80,7 @@ public static class ExecutionGuard internal static class __CodeExecGuard { public static int Calls, Jumps; - static int _start, _last, _interval, _budget, _stopped, _maxStack, _baseThread, _orphans; + static int _start, _last, _interval, _budget, _stopped, _maxStack, _baseThread, _orphans, _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 @@ -127,9 +127,10 @@ public static bool Init() return true; } - public static bool Running() => _stopped == 0 && (_orphans == 0 || !Orphaned()); + public static bool Running() => _stopped == 0 && (_orphans == 0 || _live == 0 || !Orphaned()); - static Run Current() => _thread ?? _async.Value; + // 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() { @@ -156,7 +157,14 @@ static Run Spawn(object work) return run; } - static void Done(Run run) => global::System.Threading.Interlocked.Decrement(ref run.Pending); + /// 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 @@ -164,8 +172,9 @@ static Run Spawn(object work) /// static void Do(Run run, global::System.Action work, bool swallow) { + Started(); var thread = _thread; - var flowing = _async.Value; + 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 @@ -189,6 +198,8 @@ static TResult Get(Run run, global::System.Func work) Do(run, () => value = work(), false); return value; } + _asyncWork = 1; + Started(); var thread = _thread; var flowing = _async.Value; _thread = null; @@ -276,7 +287,7 @@ static void Check() { if (_stopped == 0) { - if (_orphans != 0 && Orphaned()) + if (_orphans != 0 && _live != 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; From d5a812d32a5ba06ebe04dfdac965e49ce38202f4 Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Thu, 29 Oct 2026 01:25:39 -0700 Subject: [PATCH 5/5] code review: keep worker checks on after asynchronous task work (its unwrapped continuations carry the run) Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec.Tests/GuardTests.cs | 2 ++ dotnet/CodeExec/ExecutionGuard.cs | 13 +++++++++---- spec/contract.md | 4 +++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/dotnet/CodeExec.Tests/GuardTests.cs b/dotnet/CodeExec.Tests/GuardTests.cs index 10ad1c6..728a3ae 100644 --- a/dotnet/CodeExec.Tests/GuardTests.cs +++ b/dotnet/CodeExec.Tests/GuardTests.cs @@ -235,6 +235,8 @@ public void FinishedRunLiftsTheBudgetForCallbacksItLeftBehind() [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. diff --git a/dotnet/CodeExec/ExecutionGuard.cs b/dotnet/CodeExec/ExecutionGuard.cs index efab1b1..7caf693 100644 --- a/dotnet/CodeExec/ExecutionGuard.cs +++ b/dotnet/CodeExec/ExecutionGuard.cs @@ -80,7 +80,7 @@ public static class ExecutionGuard internal static class __CodeExecGuard { public static int Calls, Jumps; - static int _start, _last, _interval, _budget, _stopped, _maxStack, _baseThread, _orphans, _asyncWork, _live; + 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 @@ -91,7 +91,7 @@ internal static class __CodeExecGuard /// 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; + public int Ended, Spawned, Pending, Async; } public static unsafe void Arm(int budgetMs, int maxStackBytes) @@ -117,6 +117,8 @@ public static int End(object 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); } @@ -127,7 +129,7 @@ public static bool Init() return true; } - public static bool Running() => _stopped == 0 && (_orphans == 0 || _live == 0 || !Orphaned()); + 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); @@ -199,6 +201,9 @@ static TResult Get(Run run, global::System.Func work) 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; @@ -287,7 +292,7 @@ static void Check() { if (_stopped == 0) { - if (_orphans != 0 && _live != 0 && Orphaned()) + 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; diff --git a/spec/contract.md b/spec/contract.md index 23a842e..fca730b 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -172,7 +172,9 @@ the host turns them off (`CodeRunnerOptions.ExecutionGuards`): 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. + 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.