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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ Every host already has its own Roslyn constraints (Unity pins 4.8 because of `Sy
## C# in two lines

```xml
<PackageReference Include="com.IvanMurzak.CodeExec" Version="0.3.0" />
<PackageReference Include="com.IvanMurzak.CodeExec.Roslyn" Version="0.3.0" />
<PackageReference Include="com.IvanMurzak.CodeExec" Version="0.3.1" />
<PackageReference Include="com.IvanMurzak.CodeExec.Roslyn" Version="0.3.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" /> <!-- your choice, 3.8+ -->
```

Expand Down
14 changes: 14 additions & 0 deletions dotnet/CodeExec.Tests/RunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,20 @@ public static class Outer { public sealed class Inner { } }
[InlineData("Func<int, int> twice = x => x * 2;\nnew[] { 1, 2 }.Select(twice).Sum()", "6", false)] // lambda caches only
[InlineData("var seen = new List<int>();\nFunc<int, int> 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<string, int> Map = new Dictionary<string, int> { [\"a\"] = 1, [\"b\"] = 2 }; }\nM.Map[\"b\"]", "2", false)]
[InlineData("public static class I { public static readonly System.Collections.Immutable.ImmutableArray<string> 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<List<int>> L = new[] { new List<int>() }; }\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<int> L { get; } = new List<int>(); }\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> { int n; public int this[int i] => ++n; public int Count => 1; public IEnumerator<int> GetEnumerator() { yield return this[0]; } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); }\npublic static class S { public static readonly IReadOnlyList<int> 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());
Expand Down
59 changes: 53 additions & 6 deletions dotnet/CodeExec/Support.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ static string WithSeparator(string path) =>

/// <summary>
/// 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 <c>readonly</c> 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.
/// </summary>
static class StaticState
{
Expand All @@ -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;
Expand All @@ -143,6 +147,49 @@ public static bool Has(Assembly assembly)
return true;
}
}

static readonly HashSet<Type> ImmutableValues = new HashSet<Type>
{
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<string> ReadOnlyViews = new HashSet<string>
{
"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",
};

/// <summary>Immutable by declared type (spec §3.5). Arrays and pointer-sized integers (native memory) are mutable.</summary>
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);
}
}

/// <summary>Formats a runtime <see cref="Type"/> as C# source: <c>global::System.Collections.Generic.List&lt;global::System.Int32&gt;</c>.</summary>
Expand Down
2 changes: 1 addition & 1 deletion dotnet/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<Deterministic>true</Deterministic>
<Version>0.3.0</Version>
<Version>0.3.1</Version>
<Authors>Ivan Murzak</Authors>
<Copyright>Copyright (c) 2026 Ivan Murzak</Copyright>
<RepositoryUrl>https://github.com/IvanMurzak/Code-Exec</RepositoryUrl>
Expand Down
12 changes: 12 additions & 0 deletions spec/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, `IReadOnlyList<T>`, `IReadOnlyDictionary<K,V>`, `IReadOnlySet<T>`, `ReadOnlyCollection<T>`,
`ReadOnlyDictionary<K,V>`, `System.Collections.Immutable.*`). So a lookup table like
`static readonly IReadOnlyDictionary<string, int> 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<Camera>().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

Expand Down Expand Up @@ -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
Expand Down
Loading