diff --git a/README.md b/README.md index 404547e..6419718 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ Every host already has its own Roslyn constraints (Unity pins 4.8 because of `Sy ## C# in two lines ```xml - - + + ``` diff --git a/dotnet/CodeExec.Tests/RunnerTests.cs b/dotnet/CodeExec.Tests/RunnerTests.cs index 3a92da0..79f2cb8 100644 --- a/dotnet/CodeExec.Tests/RunnerTests.cs +++ b/dotnet/CodeExec.Tests/RunnerTests.cs @@ -522,6 +522,20 @@ public static class Outer { public sealed class Inner { } } [InlineData("Func twice = x => x * 2;\nnew[] { 1, 2 }.Select(twice).Sum()", "6", false)] // lambda caches only [InlineData("var seen = new List();\nFunc twice = x => x * 2;\nseen.Add(twice(1));\nnew[] { 1, 2 }.Sum() + seen.Count", "4", false)] [InlineData("const int K = 2;\nnew[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }.Length * K", "20", false)] // array data only + // readonly + immutable type: a lookup table, not state (spec §3.5) + [InlineData("static readonly string greeting = \"hi\";\ngreeting.Length", "2", false)] + [InlineData("public enum Color { Red, Green }\npublic static class D { public static readonly Color Default = Color.Green; public static readonly int? Max = 5; public static readonly TimeSpan T = TimeSpan.FromSeconds(1); }\n(int)D.Default + D.Max.Value + (int)D.T.TotalSeconds", "7", false)] + [InlineData("public static class G { public static int N { get; } = 3; }\nG.N", "3", false)] + [InlineData("public static class M { public static readonly IReadOnlyDictionary Map = new Dictionary { [\"a\"] = 1, [\"b\"] = 2 }; }\nM.Map[\"b\"]", "2", false)] + [InlineData("public static class I { public static readonly System.Collections.Immutable.ImmutableArray A = System.Collections.Immutable.ImmutableArray.Create(\"x\", \"y\"); }\nI.A.Length", "2", false)] + // readonly but mutable: arrays, mutable elements, or a mutable field next to an immutable one + [InlineData("public static class A { public static readonly int[] C = new int[1]; }\n++A.C[0]", "1", true)] + [InlineData("public static class R { public static readonly IReadOnlyList> L = new[] { new List() }; }\nR.L[0].Add(1);\nR.L[0].Count", "1", true)] + [InlineData("public static class X { public static readonly string S = \"s\"; public static int N; }\n++X.N", "1", true)] + [InlineData("public static class GL { public static List L { get; } = new List(); }\nGL.L.Add(1);\nGL.L.Count", "1", true)] + [InlineData("public static class P { public static readonly IntPtr Mem = System.Runtime.InteropServices.Marshal.AllocHGlobal(4); static P() { System.Runtime.InteropServices.Marshal.WriteInt32(Mem, 0); } }\nvar n = System.Runtime.InteropServices.Marshal.ReadInt32(P.Mem) + 1;\nSystem.Runtime.InteropServices.Marshal.WriteInt32(P.Mem, n);\nn", "1", true)] + // a view the snippet implements itself can hide state in its instance + [InlineData("public sealed class Ctr : IReadOnlyList { int n; public int this[int i] => ++n; public int Count => 1; public IEnumerator GetEnumerator() { yield return this[0]; } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); }\npublic static class S { public static readonly IReadOnlyList L = new Ctr(); }\nS.L[0]", "1", true)] public void StatefulSnippetsNeverSeeAnotherRunsStatics(string code, string expected, bool stateful) { var runner = Fixtures.Runner(configure: o => o.Host = new DefaultAssemblyHost()); diff --git a/dotnet/CodeExec/Support.cs b/dotnet/CodeExec/Support.cs index c971199..16dd3e3 100644 --- a/dotnet/CodeExec/Support.cs +++ b/dotnet/CodeExec/Support.cs @@ -119,8 +119,9 @@ static string WithSeparator(string path) => /// /// Whether a snippet assembly keeps state between runs if it is reused: a static field (an auto-property's or an - /// event's backing field included) declared by one of the agent's own types. The compiler's own types (lambda caches, - /// array-initializer data) are stateless for this purpose. + /// event's backing field included) declared by one of the agent's own types, unless it is readonly and of an + /// immutable type (spec §3.5): a lookup table or a value computed once is not state. The compiler's own types (lambda + /// caches, array-initializer data) are stateless for this purpose. /// static class StaticState { @@ -129,11 +130,14 @@ public static bool Has(Assembly assembly) const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; try { - foreach (var type in assembly.GetTypes()) + // The compiler's own types (lambda caches, array-initializer data, collection-expression wrappers) are skipped. + var types = assembly.GetTypes().Where(t => !t.IsDefined(typeof(CompilerGeneratedAttribute), false)).ToList(); + // A collection view the snippet implements (or derives) itself may keep state in its instance: then no view + // type is trusted to be immutable. + var trustViews = !types.Any(ImplementsView); + foreach (var type in types) { - if (type.IsDefined(typeof(CompilerGeneratedAttribute), false)) - continue; - if (type.GetFields(flags).Any(f => !f.IsLiteral)) + if (type.GetFields(flags).Any(f => !f.IsLiteral && !(f.IsInitOnly && IsImmutable(f.FieldType, trustViews)))) return true; } return false; @@ -143,6 +147,49 @@ public static bool Has(Assembly assembly) return true; } } + + static readonly HashSet ImmutableValues = new HashSet + { + typeof(string), typeof(decimal), typeof(DateTime), typeof(DateTimeOffset), typeof(TimeSpan), typeof(Guid), + }; + + // Read-only views: their contents cannot change through the declared type (a cast back to a mutable one can). + static readonly HashSet ReadOnlyViews = new HashSet + { + "System.Collections.Generic.IReadOnlyCollection`1", "System.Collections.Generic.IReadOnlyList`1", + "System.Collections.Generic.IReadOnlyDictionary`2", "System.Collections.Generic.IReadOnlySet`1", + "System.Collections.ObjectModel.ReadOnlyCollection`1", "System.Collections.ObjectModel.ReadOnlyDictionary`2", + }; + + /// Immutable by declared type (spec §3.5). Arrays and pointer-sized integers (native memory) are mutable. + static bool IsImmutable(Type type, bool trustViews) + { + if ((type.IsPrimitive && type != typeof(IntPtr) && type != typeof(UIntPtr)) || type.IsEnum || ImmutableValues.Contains(type)) + return true; + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + return IsImmutable(underlying, trustViews); + return trustViews && IsView(type) && type.GetGenericArguments().All(t => IsImmutable(t, trustViews)); + } + + static bool IsView(Type type) + { + if (!type.IsGenericType) + return false; + var definition = type.GetGenericTypeDefinition(); + // Nested types there are the mutable builders and enumerators. + return (definition.Namespace == "System.Collections.Immutable" && !definition.IsNested) || ReadOnlyViews.Contains(definition.FullName ?? ""); + } + + static bool ImplementsView(Type type) + { + for (var t = (Type?)type; t != null; t = t.BaseType) + { + if (IsView(t)) + return true; + } + return type.GetInterfaces().Any(IsView); + } } /// Formats a runtime as C# source: global::System.Collections.Generic.List<global::System.Int32>. diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 5833b19..373ca97 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -6,7 +6,7 @@ true $(NoWarn);CS1591 true - 0.3.0 + 0.3.1 Ivan Murzak Copyright (c) 2026 Ivan Murzak https://github.com/IvanMurzak/Code-Exec diff --git a/spec/contract.md b/spec/contract.md index 683ba86..34b8ed7 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -104,6 +104,17 @@ asked, so the snippet sees the host's live statics, never a second copy. On Mono identical requests reuse the already-loaded assembly (cache) — unless the snippet (or an include) declares static state of its own (a static field, auto-property or field-like event in one of its types): then every run loads a fresh copy, so no run ever sees another run's statics. Such a snippet costs one assembly per run until the host's domain reloads. +A `static readonly` field (or a getter-only static auto-property) is **not** state when its declared type is immutable: +`string`, a primitive other than `IntPtr` / `UIntPtr`, an enum, `decimal`, `DateTime`, `DateTimeOffset`, `TimeSpan`, +`Guid`, a nullable of one of these, or a read-only / immutable collection of immutable elements +(`IReadOnlyCollection`, `IReadOnlyList`, `IReadOnlyDictionary`, `IReadOnlySet`, `ReadOnlyCollection`, +`ReadOnlyDictionary`, `System.Collections.Immutable.*`). So a lookup table like +`static readonly IReadOnlyDictionary Map` keeps the snippet cached. Arrays are mutable +(`static readonly int[]` is state), and no collection type counts as immutable when the snippet itself implements or +derives from one of them. Consequences of reuse on Mono, by design: such a field's initializer runs once per loaded copy, +so a value read from live host state keeps its first value (`static readonly int Cameras = FindObjectsOfType().Length`, +`static readonly DateTime Started = DateTime.Now`: read live values in a method or property instead); and a host object +behind a read-only declared type, or a cast back to a mutable type, is not detected. ## 4. Python snippet semantics @@ -150,6 +161,7 @@ Lengths are in characters: UTF-16 code units in C#, code points in Python. A lim ## Changelog +- **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` (defaults 20000 / 20000 / 2000 / 20000, the caps Unreal-MCP and Godot-MCP applied on their side) and response field `truncated`; long tracebacks keep their first and last 10 frames; at most 20 diagnostics. Includes stay inside the