From 57b7f4b2b00cfa7ab0234d7ff06618fd563185fe Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Mon, 26 Oct 2026 15:06:16 -0700 Subject: [PATCH 1/3] fix: static readonly fields of immutable types are not static state (0.3.1) A snippet or include with a static readonly lookup table (string, primitive, enum, read-only / immutable collection of those) loaded a fresh assembly on every run under a host that cannot unload (Mono/Unity), leaking one assembly per run until the next domain reload. StaticState.Has now ignores readonly fields of immutable declared types. Arrays and mutable element types stay state. Contract 3.5 documents the rule; version 0.3.1 (Python unchanged). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +-- dotnet/CodeExec.Tests/RunnerTests.cs | 10 +++++++ dotnet/CodeExec/Support.cs | 40 +++++++++++++++++++++++++--- dotnet/Directory.Build.props | 2 +- spec/contract.md | 11 ++++++++ 5 files changed, 61 insertions(+), 6 deletions(-) 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..7acf636 100644 --- a/dotnet/CodeExec.Tests/RunnerTests.cs +++ b/dotnet/CodeExec.Tests/RunnerTests.cs @@ -522,6 +522,16 @@ 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)] 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..1a26ae1 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 { @@ -133,7 +134,7 @@ public static bool Has(Assembly assembly) { 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)))) return true; } return false; @@ -143,6 +144,39 @@ 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", + }; + + /// + /// Strings, primitives, enums, a few immutable BCL values (decimal, DateTime, DateTimeOffset, TimeSpan, Guid, and + /// nullables of all these), and read-only / immutable collections (by declared type) of immutable elements. Arrays + /// are mutable: static readonly int[] stays state. + /// + static bool IsImmutable(Type type) + { + if (type.IsPrimitive || type.IsEnum || ImmutableValues.Contains(type)) + return true; + if (!type.IsGenericType) + return false; + var definition = type.GetGenericTypeDefinition(); + if (definition == typeof(Nullable<>)) + return IsImmutable(type.GetGenericArguments()[0]); + var immutable = definition.Namespace == "System.Collections.Immutable" + && (definition.Name.StartsWith("Immutable", StringComparison.Ordinal) || definition.Name.StartsWith("IImmutable", StringComparison.Ordinal)); + return (immutable || ReadOnlyViews.Contains(definition.FullName ?? "")) + && type.GetGenericArguments().All(IsImmutable); + } } /// 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..e8a72f8 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -104,6 +104,14 @@ 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, 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). Two consequences of reuse on Mono, by design: +such a field's initializer runs once per loaded copy (`static readonly DateTime Started = DateTime.Now` keeps its first +value), and a cast from the declared read-only type back to a mutable one is not detected. ## 4. Python snippet semantics @@ -150,6 +158,9 @@ Lengths are in characters: UTF-16 code units in C#, code points in Python. A lim ## Changelog +- **v0.3.1** — C#: a `static readonly` field of an immutable type (§3.5) no longer counts as static state, so a snippet + or include with a lookup table is reused on Mono instead of loading one assembly per run. The Python runtime 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 From 64a45a327006876816486d8dc5db6497811a63bc Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Mon, 26 Oct 2026 15:08:16 -0700 Subject: [PATCH 2/3] simplify: Nullable.GetUnderlyingType, !IsNested for immutable collections, one-line docs Skipped: snippet-declared readonly structs as immutable (behaviour change, out of scope); foreach instead of All (no measurable gain). Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec/Support.cs | 15 ++++++--------- spec/contract.md | 4 +--- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/dotnet/CodeExec/Support.cs b/dotnet/CodeExec/Support.cs index 1a26ae1..b610ba6 100644 --- a/dotnet/CodeExec/Support.cs +++ b/dotnet/CodeExec/Support.cs @@ -158,22 +158,19 @@ public static bool Has(Assembly assembly) "System.Collections.ObjectModel.ReadOnlyCollection`1", "System.Collections.ObjectModel.ReadOnlyDictionary`2", }; - /// - /// Strings, primitives, enums, a few immutable BCL values (decimal, DateTime, DateTimeOffset, TimeSpan, Guid, and - /// nullables of all these), and read-only / immutable collections (by declared type) of immutable elements. Arrays - /// are mutable: static readonly int[] stays state. - /// + /// Immutable by declared type (spec §3.5). Arrays are mutable. static bool IsImmutable(Type type) { if (type.IsPrimitive || type.IsEnum || ImmutableValues.Contains(type)) return true; + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + return IsImmutable(underlying); if (!type.IsGenericType) return false; var definition = type.GetGenericTypeDefinition(); - if (definition == typeof(Nullable<>)) - return IsImmutable(type.GetGenericArguments()[0]); - var immutable = definition.Namespace == "System.Collections.Immutable" - && (definition.Name.StartsWith("Immutable", StringComparison.Ordinal) || definition.Name.StartsWith("IImmutable", StringComparison.Ordinal)); + // Nested types there are the mutable builders and enumerators. + var immutable = definition.Namespace == "System.Collections.Immutable" && !definition.IsNested; return (immutable || ReadOnlyViews.Contains(definition.FullName ?? "")) && type.GetGenericArguments().All(IsImmutable); } diff --git a/spec/contract.md b/spec/contract.md index e8a72f8..966348c 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -158,9 +158,7 @@ Lengths are in characters: UTF-16 code units in C#, code points in Python. A lim ## Changelog -- **v0.3.1** — C#: a `static readonly` field of an immutable type (§3.5) no longer counts as static state, so a snippet - or include with a lookup table is reused on Mono instead of loading one assembly per run. The Python runtime is - unchanged. +- **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 From 75368273bc892f9c967b018b85a4890b63c7b1ac Mon Sep 17 00:00:00 2001 From: Ivan Murzak Date: Mon, 26 Oct 2026 15:13:53 -0700 Subject: [PATCH 3/3] code review: distrust collection views the snippet implements, IntPtr is not immutable, live-state caveat in 3.5 - A snippet type implementing / deriving a read-only or immutable collection type can keep state in its instance: then no view type counts as immutable (compiler-generated types excluded). - IntPtr / UIntPtr point at mutable native memory: state. - Spec 3.5: initializers reading live host state keep their first value on Mono; host objects behind a read-only type. - Tests: getter-only mutable auto-property, IntPtr, a snippet-implemented IReadOnlyList. Co-Authored-By: Claude Opus 5.5 --- dotnet/CodeExec.Tests/RunnerTests.cs | 4 +++ dotnet/CodeExec/Support.cs | 38 ++++++++++++++++++++-------- spec/contract.md | 17 ++++++++----- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/dotnet/CodeExec.Tests/RunnerTests.cs b/dotnet/CodeExec.Tests/RunnerTests.cs index 7acf636..79f2cb8 100644 --- a/dotnet/CodeExec.Tests/RunnerTests.cs +++ b/dotnet/CodeExec.Tests/RunnerTests.cs @@ -532,6 +532,10 @@ public static class Outer { public sealed class Inner { } } [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 b610ba6..16dd3e3 100644 --- a/dotnet/CodeExec/Support.cs +++ b/dotnet/CodeExec/Support.cs @@ -130,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 && !(f.IsInitOnly && IsImmutable(f.FieldType)))) + if (type.GetFields(flags).Any(f => !f.IsLiteral && !(f.IsInitOnly && IsImmutable(f.FieldType, trustViews)))) return true; } return false; @@ -158,21 +161,34 @@ public static bool Has(Assembly assembly) "System.Collections.ObjectModel.ReadOnlyCollection`1", "System.Collections.ObjectModel.ReadOnlyDictionary`2", }; - /// Immutable by declared type (spec §3.5). Arrays are mutable. - static bool IsImmutable(Type type) + /// 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.IsEnum || ImmutableValues.Contains(type)) + 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); + 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. - var immutable = definition.Namespace == "System.Collections.Immutable" && !definition.IsNested; - return (immutable || ReadOnlyViews.Contains(definition.FullName ?? "")) - && type.GetGenericArguments().All(IsImmutable); + 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); } } diff --git a/spec/contract.md b/spec/contract.md index 966348c..34b8ed7 100644 --- a/spec/contract.md +++ b/spec/contract.md @@ -105,13 +105,16 @@ identical requests reuse the already-loaded assembly (cache) — unless the snip 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, 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). Two consequences of reuse on Mono, by design: -such a field's initializer runs once per loaded copy (`static readonly DateTime Started = DateTime.Now` keeps its first -value), and a cast from the declared read-only type back to a mutable one is not detected. +`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