fix(scripting+alarms): close remaining re-review findings

Single commit covering the four small/medium fixes from the updated
code review.

Core.Scripting-014 (Medium, Concurrency):
  CompiledScriptCache.Clear() used the key-only TryRemove(key, out var
  lazy) overload — same race shape Core.Scripting-006 closed in
  GetOrCompile's catch block. A concurrent re-add between snapshot and
  TryRemove was evicted + disposed while the new caller still held it.
  Replaced with the value-scoped TryRemove(KeyValuePair<,>) overload.
  Regression test
  Clear_uses_value_scoped_TryRemove_so_a_race_inserted_entry_survives
  added.

Core.Scripting-013 (Medium, Security):
  Hand-rolled BuildWrapperSource pastes user source between literal
  braces; brace-balanced source could inject sibling methods/classes
  alongside CompiledScript.Run. Analyzer still walked the injected
  members so it wasn't a direct escape, but it relaxed the documented
  'method body' authoring contract. Added EnforceSingleRunMember:
  after ParseText, the compilation unit must hold exactly one type
  (CompiledScript) and that type must hold exactly one member (the Run
  method). Any deviation throws CompilationErrorException with LMX001/
  LMX002 diagnostic IDs and a Core.Scripting-013 reference in the
  message. Two regression tests added covering the sibling-method and
  sibling-class injection vectors.

Core.Scripting-015 (Low, Correctness, latent):
  ToCSharpTypeName's generic branch truncated at the first backtick via
  IndexOf, silently dropping closed args of nested-generic shapes
  (Outer<T>.Inner<U>). No production caller exercises this shape today
  (all TContext/TResult are top-level non-nested), so the bug was
  latent. Rewrote the generic branch to walk the FullName segment-by-
  segment, consuming generic args per segment so nested shapes emit
  valid C# (global::Ns.Outer<T>.Inner<U> rather than the broken
  Outer<T,U>).

Core.ScriptedAlarms-013 (Low, Documentation):
  The internal test accessors TryGetScratchReadCacheForTest /
  TryGetScratchContextForTest return live mutable scratch refilled in
  place under _evalGate. XML docs didn't warn future test authors about
  the synchronization contract. Added a <remarks> block to each
  documenting the only-safe-on-quiesced-engine + identity-or-single-key
  contract.

Verification (suites green):
  Core.Scripting.Tests: 110/110 (was 107 — +3 new rejection/race tests)
  Core.ScriptedAlarms.Tests: 67/67 (unchanged — doc-only fix)
  Core.VirtualTags.Tests: 57/57 (unchanged)

After this commit, all 12 findings from the updated re-review are
closed (10 Resolved, 1 Won't Fix none, 1 Deferred — Driver.Galaxy-017).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 18:00:59 -04:00
parent c2abbf45bd
commit 23d59d73f2
8 changed files with 311 additions and 29 deletions

View File

@@ -222,6 +222,72 @@ public sealed class CompiledScriptCacheTests
cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure");
}
[Fact]
public void Clear_uses_value_scoped_TryRemove_so_a_race_inserted_entry_survives()
{
// Regression for Core.Scripting-014: Clear() previously used the key-only
// TryRemove(key, out var lazy) overload, which is value-blind. If a
// concurrent GetOrCompile re-inserts a fresh Lazy under the same key
// between Clear's snapshot and the TryRemove, the buggy overload evicts
// the fresh entry and disposes its evaluator while the concurrent
// caller still holds + intends to invoke it. The fixed
// TryRemove(KeyValuePair<,>) overload compares value identity.
//
// Single-threaded test that models the race window: snapshot the
// dictionary like Clear does, mutate the dictionary mid-flight (modelling
// Thread B's GetOrCompile), then drive the same TryRemove(KeyValuePair<,>)
// overload Clear now uses. The buggy key-only overload would evict the
// fresh entry; the fixed value-scoped overload leaves it alone. This
// verifies the *semantic* of Clear's code path — actually intercepting
// a real Clear() invocation mid-loop would require either two threads
// (flaky) or a Dispose side-effect that re-adds within the loop
// iteration (only works with multiple snapshotted entries).
var cache = new CompiledScriptCache<FakeScriptContext, int>();
var cacheField = typeof(CompiledScriptCache<FakeScriptContext, int>)
.GetField("_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
var dict = (System.Collections.Concurrent.ConcurrentDictionary<
string, Lazy<ScriptEvaluator<FakeScriptContext, int>>>)cacheField!.GetValue(cache)!;
var hashSourceMethod = typeof(CompiledScriptCache<FakeScriptContext, int>)
.GetMethod("HashSource", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
var key = (string)hashSourceMethod!.Invoke(null, ["""return 1;"""])!;
var firstGen = new Lazy<ScriptEvaluator<FakeScriptContext, int>>(
() => ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;"""),
LazyThreadSafetyMode.ExecutionAndPublication);
dict[key] = firstGen;
// Snapshot like Clear's first line does.
var snapshot = dict.ToArray();
snapshot.Length.ShouldBe(1);
// Race window: a concurrent GetOrCompile inserts a fresh Lazy under the
// same key. The dict now holds `fresh`, but the snapshot still references
// `firstGen`.
var fresh = new Lazy<ScriptEvaluator<FakeScriptContext, int>>(
() => ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;"""),
LazyThreadSafetyMode.ExecutionAndPublication);
dict[key] = fresh;
// The fixed Clear uses the value-scoped TryRemove(KeyValuePair<,>) overload.
// Drive it directly with our snapshot entry — the value comparison sees
// firstGen ≠ fresh and leaves the entry intact.
foreach (var entry in snapshot)
{
var removed = ((System.Collections.Generic.ICollection<
System.Collections.Generic.KeyValuePair<string, Lazy<ScriptEvaluator<FakeScriptContext, int>>>>)dict)
.Remove(entry);
removed.ShouldBeFalse(
"value-scoped removal must reject the stale snapshot entry — the dict " +
"now holds `fresh`, not `firstGen`. The buggy key-only TryRemove would " +
"have returned true and evicted the fresh entry (Core.Scripting-014).");
}
dict.ContainsKey(key).ShouldBeTrue(
"the fresh entry inserted during the race window must survive Clear().");
ReferenceEquals(dict[key], fresh).ShouldBeTrue(
"the surviving entry must be the fresh Lazy, not the one Clear() snapshotted.");
}
// --- Core.Scripting-008: collectible AssemblyLoadContext unload ---
[Fact]

View File

@@ -494,4 +494,40 @@ public sealed class ScriptSandboxTests
return 0;
"""));
}
// --- Core.Scripting-013: wrapper-source injection ---
[Fact]
public void Rejects_sibling_method_injection_via_balanced_braces()
{
// Core.Scripting-013 — a brace-balanced source that closes Run early and
// declares a sibling method inside CompiledScript. The analyzer would still
// walk the injected member, but the documented "method body" contract
// forbids the shape outright now.
var ex = Should.Throw<CompilationErrorException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
return 0;
} public static int Evil() { return 1;
"""));
ex.Message.ShouldContain("Core.Scripting-013");
}
[Fact]
public void Rejects_sibling_class_injection_via_balanced_braces()
{
// Same shape, but injecting an entire sibling class inside the synthesized
// namespace. Reject at the type-count check.
var ex = Should.Throw<CompilationErrorException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
return 0;
}
}
public static class CompiledScript2 { public static int M() { return 0; } }
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled { public static class CompiledScript3 { public static int M() {
return 0;
"""));
ex.Message.ShouldContain("Core.Scripting-013");
}
}