fix(core-scripting): resolve Low code-review findings (Core.Scripting-005,006,008,009,011)
- Core.Scripting-005: DependencyExtractor.HandleTagCall now recognises raw-string literal paths by checking the StringLiteralExpression node kind instead of the legacy StringLiteralToken kind. - Core.Scripting-006: scope CompiledScriptCache failed-compile eviction with TryRemove(KeyValuePair) so a racing retry entry is not evicted. - Core.Scripting-008: document the per-publish assembly accretion as an accepted limitation in docs/VirtualTags.md. - Core.Scripting-009: enumerate the authoritative deny-list (namespace prefixes + type-granular denies) in the Phase 7 decision-#6 entry to match ForbiddenTypeAnalyzer. - Core.Scripting-011: pin ScriptSandbox.Build, ScriptContext.Deadband boundary semantics, and end-to-end factory + companion-sink integration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -148,4 +148,77 @@ public sealed class CompiledScriptCacheTests
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_compile_eviction_does_not_remove_a_concurrent_retry_entry()
|
||||
{
|
||||
// Regression for Core.Scripting-006: when a faulted Lazy is observed by a thread,
|
||||
// the eviction must scope to that specific Lazy instance, not the key. If a
|
||||
// concurrent retry has already inserted a fresh Lazy under the same key between
|
||||
// the throw and the catch-block removal, the buggy TryRemove(key, out _) overload
|
||||
// evicts the retry entry. The fixed TryRemove(KeyValuePair<,>) overload compares
|
||||
// value identity, so only the faulted Lazy is removed.
|
||||
//
|
||||
// Deterministic setup: pre-populate the cache's internal dictionary with a
|
||||
// faulted Lazy whose factory itself swaps the entry to a fresh Lazy as a side
|
||||
// effect during the throw. By the time GetOrCompile reaches its catch block, the
|
||||
// dictionary holds the fresh entry under the same key — exactly the race window
|
||||
// the finding describes. The fix must leave the fresh entry in place.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
|
||||
// Reach the private _cache + HashSource via reflection — they're private, so
|
||||
// InternalsVisibleTo doesn't help.
|
||||
var cacheField = typeof(CompiledScriptCache<FakeScriptContext, int>)
|
||||
.GetField("_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
|
||||
cacheField.ShouldNotBeNull();
|
||||
var dict = (System.Collections.Concurrent.ConcurrentDictionary<
|
||||
string, Lazy<ScriptEvaluator<FakeScriptContext, int>>>)cacheField!.GetValue(cache)!;
|
||||
|
||||
const string source = """return 7;""";
|
||||
var hashSourceMethod = typeof(CompiledScriptCache<FakeScriptContext, int>)
|
||||
.GetMethod("HashSource", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
|
||||
hashSourceMethod.ShouldNotBeNull();
|
||||
var key = (string)hashSourceMethod!.Invoke(null, [source])!;
|
||||
|
||||
// The fresh Lazy is what a concurrent retry would have inserted between the
|
||||
// faulted-throw and the catch's removal. Materialise it eagerly so we have a
|
||||
// stable reference to assert identity against.
|
||||
var fresh = new Lazy<ScriptEvaluator<FakeScriptContext, int>>(
|
||||
() => ScriptEvaluator<FakeScriptContext, int>.Compile(source),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
// The faulted Lazy throws — but only after swapping its own dictionary entry
|
||||
// for the fresh Lazy, modelling the race window between the throw and the
|
||||
// catch-block eviction.
|
||||
var faulted = new Lazy<ScriptEvaluator<FakeScriptContext, int>>(
|
||||
() =>
|
||||
{
|
||||
dict[key] = fresh;
|
||||
throw new InvalidOperationException("bad compile");
|
||||
},
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
dict[key] = faulted;
|
||||
|
||||
// Drive GetOrCompile through the public API. It observes the faulted Lazy
|
||||
// currently under `key`, invokes .Value (which swaps in the fresh Lazy then
|
||||
// throws), and runs the catch block's eviction. The fix removes only the
|
||||
// specific faulted Lazy instance; the fresh entry survives.
|
||||
Should.Throw<InvalidOperationException>(() => cache.GetOrCompile(source));
|
||||
|
||||
dict.ContainsKey(key).ShouldBeTrue(
|
||||
"the fresh retry entry that won the race must survive the faulted Lazy eviction (Core.Scripting-006)");
|
||||
ReferenceEquals(dict[key], fresh).ShouldBeTrue(
|
||||
"the entry under the key must still be the fresh Lazy — an unconditional TryRemove(key) would have evicted it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_compile_path_still_evicts_its_own_faulted_entry()
|
||||
{
|
||||
// Companion to the race test above: confirm the fix's value-scoped eviction
|
||||
// still removes the actual faulted Lazy (so retries with corrected source can
|
||||
// succeed — the original Core.Scripting test that locked the contract).
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||
cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user