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:
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user