diff --git a/.github/workflows/dotnet10.yml b/.github/workflows/dotnet10.yml index c68464460..5b9f04f68 100644 --- a/.github/workflows/dotnet10.yml +++ b/.github/workflows/dotnet10.yml @@ -7,7 +7,7 @@ # # build ─┬─ test-fast CLI + Core + Stdlib + LSP (one step each) # ├─ compiler (matrix 1..7) the pre-#2178 "Test Compiler" step, sharded by namespace -# ├─ sweep (matrix of 5) one job per GapDiscovery sweep, filters unchanged +# ├─ sweep (matrix of 6) one job per GapDiscovery sweep, filters unchanged # ├─ stdlib-spy-gates .spy native compile + generated-C# staleness gates # └─ compiler-shard-partition proves the shards partition the main-step filter # ci-summary (needs all, always runs) the single check to require: fails unless every job @@ -52,7 +52,12 @@ env: # (MetamorphicCorpusInvarianceTests, which spawns a dotnet subprocess per pair) out of CI. # The CPython execution oracle likewise earned its own job when #1202 took it from a # 60-program sample to the full ~565-cell pool. - COMPILER_MAIN_FILTER: "Category!=Benchmark&FullyQualifiedName!~InteropConformance&FullyQualifiedName!~MetamorphicCorpus&FullyQualifiedName!~DifferentialExecution&FullyQualifiedName!~FormatSpecDifferential&FullyQualifiedName!~FormatterEmitInvariance" + # The in-process isolation sweep (#2250) is excluded by Category AND name, not by name alone: + # InProcessIsolationSweepTests also carries a regular-suite slice (threads {1, 2} x 1 + # repetition) and its controls, which must keep running in the shards — a bare + # `FullyQualifiedName!~InProcessIsolation` would leave them selected by no step + # (CiFilterCoverageConformanceTests). + COMPILER_MAIN_FILTER: "Category!=Benchmark&FullyQualifiedName!~InteropConformance&FullyQualifiedName!~MetamorphicCorpus&FullyQualifiedName!~DifferentialExecution&FullyQualifiedName!~FormatSpecDifferential&FullyQualifiedName!~FormatterEmitInvariance&(Category!=GapDiscovery|FullyQualifiedName!~InProcessIsolation)" # Compiler shard buckets (#2178). Shard n runs COMPILER_MAIN_FILTER&COMPILER_SHARD_. # PARTITION INVARIANT: shards 1–6 are positive namespace buckets (shard 5 excludes shard 4's # sub-bucket by negation); shard 7 is "everything else" — the negation of every other bucket @@ -322,6 +327,18 @@ jobs: - name: formatter-emit-invariance filter: "Category=GapDiscovery&FullyQualifiedName~FormatterEmitInvariance" python: false + # In-process isolation sweep (P54, #2250). A compile is a function of its inputs alone: + # seeded per-thread schedules over a corpus (the #2250 cell programs + a stride-16 + # fixture sample) x instance sharing {compiler-per-thread, api-per-thread, api-shared} + # x 8 threads x 8 repetitions x {analyze, compile-to-IL}, each answer compared with the + # same (program, operation) run alone on a fresh instance. Emits + # .claude/tmp/in-process-isolation-report.json and ratchets + # src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt (drain-on-fix; + # `probabilistic` rows are reported, not judged). Only the GapDiscovery mode runs here: + # the class's regular slice (threads {1, 2} x 1) stays in the Compiler shards. + - name: in-process-isolation + filter: "Category=GapDiscovery&FullyQualifiedName~InProcessIsolation" + python: false steps: - uses: actions/checkout@v7 @@ -539,7 +556,7 @@ jobs: NS = "{http://microsoft.com/schemas/VisualStudio/TeamTest/2010}" shards = sorted(int(k[len("COMPILER_SHARD_"):]) for k in os.environ if k.startswith("COMPILER_SHARD_")) sweeps = ["interop-conformance", "metamorphic-corpus", "differential-execution", - "format-spec-differential", "formatter-emit-invariance"] + "format-spec-differential", "formatter-emit-invariance", "in-process-isolation"] expected = (["cli", "core", "stdlib", "lsp", "windows"] + [f"compiler-shard-{n}" for n in shards] + [f"sweep-{s}" for s in sweeps]) found = {os.path.basename(p)[:-4]: p for p in glob.glob("trx/**/*.trx", recursive=True)} diff --git a/build_tools/generate_stdlib_docs.py b/build_tools/generate_stdlib_docs.py index 7a80fbc35..8b5c3db07 100644 --- a/build_tools/generate_stdlib_docs.py +++ b/build_tools/generate_stdlib_docs.py @@ -193,7 +193,7 @@ def sharpy_field_name(cs_name: str, attr_lines: Optional[list] = None) -> str: "Sharpy.Bytes": "bytes", "Slice": "slice", "Sharpy.Slice": "slice", - # `RegisterType(BuiltinNames.Complex, typeof(Sharpy.Complex))` — Python's spelling (#1362, #2163). + # `Register(BuiltinNames.Complex, typeof(Sharpy.Complex))` — Python's spelling (#1362, #2163). "Complex": "complex", "Sharpy.Complex": "complex", } @@ -201,16 +201,16 @@ def sharpy_field_name(cs_name: str, attr_lines: Optional[list] = None) -> str: # Generic types, keyed by the C# outer name. Every value is the name the compiler resolves for that # CLR type (#2163), cited by where it is registered — a page may only spell a type the way a Sharpy # user can write it: -# list / dict / set BuiltinRegistry `RegisterType("list", typeof(Sharpy.List<>))`, etc. -# IEnumerable `RegisterType(BuiltinNames.IEnumerable, typeof(IEnumerable<>))`; the spec's +# list / dict / set BuiltinRegistry `Register("list", typeof(Sharpy.List<>))`, etc. +# IEnumerable `Register(BuiltinNames.IEnumerable, typeof(IEnumerable<>))`; the spec's # spelling (collection_types.md "`IEnumerable[T]` for a read-only ...", # dunder_methods.md `__iter__`). NOT `Iterable`: no Sharpy surface declares # it (`def f(xs: Iterable[int])` is SPY0202 "Generic type 'Iterable' not found"). -# IEnumerator `RegisterType(BuiltinNames.IEnumerator, typeof(IEnumerator<>))`. NOT +# IEnumerator `Register(BuiltinNames.IEnumerator, typeof(IEnumerator<>))`. NOT # `Iterator`: that is the registered abstract `Sharpy.Iterator`, another type. -# Iterator `RegisterType(BuiltinNames.Iterator, typeof(Sharpy.Iterator<>))`. -# Optional / Result `RegisterType("Optional", ...)`, `RegisterType("Result", ...)`. -# tuple `RegisterType(BuiltinNames.Tuple, typeof(System.ValueTuple))`. +# Iterator `Register(BuiltinNames.Iterator, typeof(Sharpy.Iterator<>))`. +# Optional / Result `Register("Optional", ...)`, `Register("Result", ...)`. +# tuple `Register(BuiltinNames.Tuple, typeof(System.ValueTuple))`. # `IComparer` has no entry: it renders as itself, a System.Collections.Generic interface the # compiler resolves bare. The `Comparer` it used to render as is `SCG.Comparer`, another type. _GENERIC_TYPE_MAP: dict[str, str] = { diff --git a/build_tools/spec_blocks_allowlist.txt b/build_tools/spec_blocks_allowlist.txt index 422a7a903..94a7d3ebf 100644 --- a/build_tools/spec_blocks_allowlist.txt +++ b/build_tools/spec_blocks_allowlist.txt @@ -161,6 +161,7 @@ function_parameters.md::4903c78545 # SPY0200 first #1952 function_parameters.md::61cf31491b # SPY0353 first #1952 function_parameters.md::62211a9973 # SPY0234 first #1952 function_parameters.md::679a768603 # SPY0109 first #1952 +function_parameters.md::695c88ab70 # SPY0220 first #2314 function_types.md::24acd11b25 # SPY0104 first #1977 function_types.md::e891fe1fdc # SPY0104 first #1977 function_variadic_arguments.md::07cd79d7a4 # SPY0202 first #1952 @@ -309,10 +310,10 @@ operator_precedence.md::742e989df5 # SPY0200 first #1952 operator_precedence.md::96a81f8939 # SPY0200 first #1952 operator_precedence.md::a7c0baf4de # SPY0200 first #1952 operator_precedence.md::dcb61c1e61 # SPY0462 first #1952 +overload_resolution.md::4270141db4 # SPY0220 first #2314 overload_resolution.md::8ea322663a # SPY0353 first #1952 overload_resolution.md::9c88e1a396 # SPY0220 first #1952 overload_resolution.md::b4729efa50 # SPY0220 first #1952 -overload_resolution.md::e5437cb11f # SPY0220 first #1952 parameter_modifiers.md::4579cd7cd3 # SPY0204 first #2145 parameter_modifiers.md::b8922611be # SPY0600 first #2144 partial_application.md::2625b8a12d # SPY0343 first #1952 diff --git a/build_tools/tests/test_generate_stdlib_docs.py b/build_tools/tests/test_generate_stdlib_docs.py index 0c2a0c4dc..5c1847e8e 100644 --- a/build_tools/tests/test_generate_stdlib_docs.py +++ b/build_tools/tests/test_generate_stdlib_docs.py @@ -2877,13 +2877,13 @@ def _spec_primitive_roster() -> set[str]: def _registered_builtin_types() -> dict[str, str]: - """Every `RegisterType(, typeof())` in BuiltinRegistry.cs: Sharpy name -> CLR short name.""" + """Every `Register(, typeof())` in BuiltinRegistry.LoadBuiltins: Sharpy name -> CLR short name.""" names = (_COMPILER_SRC / "Shared" / "BuiltinNames.cs").read_text(encoding="utf-8") consts = dict(re.findall(r'public const string (\w+) = "([^"]+)";', names)) registry = _REGISTRY_CS.read_text(encoding="utf-8") registered = {} for m in re.finditer( - r'RegisterType\(\s*(?:"(\w+)"|BuiltinNames\.(\w+))\s*,\s*typeof\(([^()]*)\)', registry + r'\bRegister\(\s*(?:"(\w+)"|BuiltinNames\.(\w+))\s*,\s*typeof\(([^()]*)\)', registry ): sharpy = m.group(1) or consts[m.group(2)] clr = re.sub(r"<[^<>]*>$", "", m.group(3)).replace("::", ".").rsplit(".", 1)[-1] @@ -2892,7 +2892,7 @@ def _registered_builtin_types() -> dict[str, str]: def _registered_generic_builtins() -> set[str]: - """The `RegisterType(..., isGeneric: true, ...)` names: written without type arguments, such a + """The `Register(..., isGeneric: true, ...)` names: written without type arguments, such a name does not denote the non-generic CLR type the C# meant (`System.Collections.IEnumerable`).""" names = (_COMPILER_SRC / "Shared" / "BuiltinNames.cs").read_text(encoding="utf-8") consts = dict(re.findall(r'public const string (\w+) = "([^"]+)";', names)) @@ -2900,7 +2900,7 @@ def _registered_generic_builtins() -> set[str]: return { m.group(1) or consts[m.group(2)] for m in re.finditer( - r'RegisterType\(\s*(?:"(\w+)"|BuiltinNames\.(\w+))\s*,[^;]*isGeneric:\s*true', registry + r'\bRegister\(\s*(?:"(\w+)"|BuiltinNames\.(\w+))\s*,[^;]*isGeneric:\s*true', registry ) } @@ -3416,13 +3416,13 @@ def test_second_file_partial_members_attach_to_the_type(self, tmp_path: Path): _MAPPING_IDENTITY = [ # (C# spelling, rendered Sharpy, cited file, text that must be in it) ("IEnumerable", "IEnumerable[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - "RegisterType(BuiltinNames.IEnumerable, typeof(IEnumerable<>)"), + "Register(BuiltinNames.IEnumerable, typeof(IEnumerable<>)"), ("System.Collections.Generic.IEnumerable", "IEnumerable[int]", "docs/language_specification/collection_types.md", "`IEnumerable[T]` for a read-only"), ("IEnumerator", "IEnumerator[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - "RegisterType(BuiltinNames.IEnumerator, typeof(IEnumerator<>)"), + "Register(BuiltinNames.IEnumerator, typeof(IEnumerator<>)"), ("Iterator", "Iterator[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - "RegisterType(BuiltinNames.Iterator, typeof(SharpyRT::Sharpy.Iterator<>)"), + "Register(BuiltinNames.Iterator, typeof(SharpyRT::Sharpy.Iterator<>)"), ("IComparer", "IComparer[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", '"System.Collections.Generic",'), ("int[]", "array[int]", "docs/language_specification/primitive_types.md", "| `array[T]` | `T[]` |"), @@ -3440,27 +3440,27 @@ def test_second_file_partial_members_attach_to_the_type(self, tmp_path: Path): "docs/language_specification/nullable_types.md", "T?"), ("int?", "int | None", "docs/language_specification/nullable_types.md", "T?"), ("List", "list[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("list", typeof(SharpyRT::Sharpy.List<>)'), + 'Register("list", typeof(SharpyRT::Sharpy.List<>)'), ("Dict", "dict[str, int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("dict", typeof(SharpyRT::Sharpy.Dict<,>)'), + 'Register("dict", typeof(SharpyRT::Sharpy.Dict<,>)'), ("Set", "set[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("set", typeof(SharpyRT::Sharpy.Set<>)'), + 'Register("set", typeof(SharpyRT::Sharpy.Set<>)'), ("Bytes", "bytes", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("bytes", typeof(SharpyRT::Sharpy.Bytes)'), + 'Register("bytes", typeof(SharpyRT::Sharpy.Bytes)'), ("Complex", "complex", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - "RegisterType(BuiltinNames.Complex, typeof(SharpyRT::Sharpy.Complex)"), + "Register(BuiltinNames.Complex, typeof(SharpyRT::Sharpy.Complex)"), ("Slice", "slice", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("slice", typeof(SharpyRT::Sharpy.Slice)'), + 'Register("slice", typeof(SharpyRT::Sharpy.Slice)'), ("Optional", "Optional[int]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("Optional", typeof(SharpyRT::Sharpy.Optional<>)'), + 'Register("Optional", typeof(SharpyRT::Sharpy.Optional<>)'), ("Result", "Result[int, str]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("Result", typeof(SharpyRT::Sharpy.Result<,>)'), + 'Register("Result", typeof(SharpyRT::Sharpy.Result<,>)'), ("ValueTuple", "tuple[int, str]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - "RegisterType(BuiltinNames.Tuple, typeof(System.ValueTuple)"), + "Register(BuiltinNames.Tuple, typeof(System.ValueTuple)"), ("(int, string)", "tuple[int, str]", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - "RegisterType(BuiltinNames.Tuple, typeof(System.ValueTuple)"), + "Register(BuiltinNames.Tuple, typeof(System.ValueTuple)"), ("void", "None", "src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs", - 'RegisterType("None", typeof(void)'), + 'Register("None", typeof(void)'), ("System.Runtime.CompilerServices.ITuple", "system.runtime.compiler_services.ITuple", "src/Sharpy.Compiler/Semantic/Registry/ModuleRegistry.cs", '"system.text.regular_expressions"'), ("System.Net.Sockets.SocketException", "SocketException", diff --git a/docs/architecture.md b/docs/architecture.md index 1ef9f1064..218ff5198 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,7 +36,7 @@ Sharpy is a statically-typed Pythonic language for .NET. Source `.spy` files com **Pass 3 — Type Checking** (`TypeChecker.cs`, split into partial files by area: Definitions, Expressions, Expressions.Access/.Calls/.Lambdas, Literals, Operators, Statements, Patterns, Utilities): Traverses the AST, infers types, records them in `SemanticInfo`, then runs the `ValidationPipeline`. -**Key registries**: `OperatorRegistry`, `ProtocolRegistry`, `BuiltinRegistry`, `ModuleRegistry`, `PrimitiveCatalog` (source of truth for primitive types and CLR mappings). +**Key registries**: `OperatorRegistry`, `ProtocolRegistry`, `BuiltinRegistry`, `ModuleRegistry`, `PrimitiveCatalog` (source of truth for primitive types and CLR mappings). A compilation sees exactly `{framework} ∪ {its own references}`: every CLR name/namespace question goes through the `ModuleRegistry`'s `AssemblyVisibility` (`Discovery/AssemblyVisibility.cs` — a process-constant index of the trusted-platform framework assemblies plus the assemblies this registry loaded; `FrameworkOnly` for a compile with no registry), never through `AppDomain` or `Type.GetType`; references load into a collectible `ReferenceLoadContext` keyed by the reference set, so a same-named assembly from another path is its own copy (`ProcessWideResolutionScanTests` keeps every process-wide resolution or load site in that one seam). The master `BuiltinRegistry` a `CompilerApi` shares across analyses is frozen after construction — a write to a master symbol throws (#2250). **Store checks** go through `TypeChecker.CheckStore` (`TypeChecker.StoreConversion.cs`) — one entry point for every store position (declaration, assignment, return, yield, argument, default, collection element, walrus, lambda body, etc.). Value-shape predicates (integer constant conversion, float32/decimal literal narrowing, literal-derived string into `LiteralString`) live in `ImplicitConversions.cs`. diff --git a/docs/design/gap-discovery-contracts.md b/docs/design/gap-discovery-contracts.md index 35f27bc1a..68f668939 100644 --- a/docs/design/gap-discovery-contracts.md +++ b/docs/design/gap-discovery-contracts.md @@ -55,6 +55,7 @@ deployment closure (`StandaloneDeploymentTests` for the used stdlib closure; `Re | Code-action source shape (#2232; outside it #2294, #2296) | Every LSP code action that moves or re-emits existing source text keeps every moved line byte-identical modulo ONE uniform indentation offset for its region (a line that starts inside a multi-line literal is byte-identical), keeps every comment and blank line of the region, replaces whole lines up to the last moved statement's TRUE last line and no further, writes the document's own line break, counts lines as the client does (`\r\n`, `\n`, lone `\r`), is offered regardless of the document's line break, and never turns a parsing document into one that does not parse; a statement the action deletes takes its trailing comment with it (D5); the applied text keeps the source's error-code multiset (the `diagnostics` bucket — its rows are #2294 for synthesized type spellings and #2296 for meaning changes, outside the shape contract) | `CodeActionShapeSweepTests` (regular suite, `Sharpy.Lsp.Tests`) — the real `SharpyCodeActionHandler` with the seven production providers driven in process by `LspCodeActionDriver` (strict edit applier `LspFormattingDriver.ApplyStrict`; `Providers` totality over the assembly's `ICodeActionProvider` implementors anchored to the literal 7; the 14-prefix `Actions` roster total over the `Title =` literals a Roslyn scan of `src/Sharpy.Lsp/Refactoring/` finds, identifier initializers resolved) — over every single-file fixture that parses (`SweepCorpus`) × four twins (identity, crlf, lone cr, comment-injected) × every request the AST admits (cursor at each `if`/`match`/declaration/class/call, every body selected whole and its first statement alone, every initializer selected exactly, every quick-fix diagnostic as context), each action judged on the request kinds that target it, under eight buckets (edits, offer, parse, diagnostics, comment — comment TRIVIA of the re-lexed applied text, line, blank, lineBreak) chosen by the action's shape class (Move / Insert / Delete / Rewrite); non-identity twins on a stride-8 stem sample and `diagnostics` on a stride-8 cell sample (judged only when the applied text parses), `SHARPY_CODEACTION_SWEEP_FULL=1` runs all; ratchets `code-action-shape-allowlist.txt` as PINS, one per (action, twin, bucket, cite): the exact failing-stem count and a SHA-256 digest of the sorted stem list, exact in regular and FULL mode, a stale pin red (populated at P47 Phase 1 with 182 pins — 132 #2232, 15 #2294, 35 #2296 — and its #2232 pins drained in P47 Phase 2); one positive and one clean control per bucket. The byte-exact expected text of an action, the axis the generator cannot judge, is the hand tests of `Refactoring/` (`RegionMoverTests` and siblings) | #2232 #2294 #2296 #2295 | | Contextual-keyword escape totality (#2166) | A backtick-escaped contextual keyword is an identifier at every site: an escaped soft keyword parses as a fresh identifier would. The predicate "this token is the contextual keyword *k*" is `Identifier ∧ ¬IsBacktickEscaped ∧ Value == k` (`Parser.IsContextualKeyword`) and "this node is the placeholder" is `Identifier { Name: "_", IsNameBacktickEscaped: false }` (`Parser.IsPlaceholder`), over ONE roster (`ContextualKeywords.All`, 13 words) | `ContextualKeywordEscapeSweepTests` (regular suite) — every corpus fixture of `FormatterMeaningPreservationSweepTests` × every unescaped roster-word identifier token at EVERY position (ordinary-identifier positions are the sweep's own green controls, so no position predicate can go stale): the escaped twin (that token in backticks) and the renamed twin (a fresh identifier of the escaped spelling's length, byte-aligned) must have the same `ContextualKeywordEscapeOracle.Outcome` — the parse-diagnostic `(code, line, column)` multiset, else the preorder AST shape (record types + enum-valued properties); one cell per (fixture, token value), census line `CKE-CENSUS`; ratchets `contextual-keyword-escape-allowlist.txt` (`stem token # #issue reason`, drain-on-fix) + `Parser/ContextualKeywordEscapeMatrixTests` — 26 (keyword, site) rows × {bare (construct present), renamed (ground truth), escaped (construct absent, named node kind or parse error, escaped ≡ renamed)} + binding-target/ordinary-read rows for every keyword, roster totality anchored to the literal 13, in-file `KnownRed` drain-on-fix | #2166 | | CLI input totality (#2173, #2223) | Every option every `sharpyc` command accepts — its own and the recursive globals — is honoured, refused for that input kind with a message, or documented as ignored in `--help`, never silently dropped; ONE registry (`CommandOptionEffects`, keyed by command path) generates the help notes and refusals, and the globals rule makes a command that compiles honour all nine globals and one that analyses honour the seven analysis-facing ones | `CliOptionEffectSweepTests` — reflective walk of the real root (every leaf command × own + recursive options, `compile` × {.spy, .spyproj}): refused cells are invoked, ignored cells' `--help` text is checked, honoured cells are observed by a named evidence test or a `GlobalOptionProbes` probe run on that command; synthetic-grammar positive controls; allowlist `src/Sharpy.Cli.Tests/cli-option-effect-allowlist.txt` | #2223 | +| In-process isolation (#2250; P54) | A compile, and every semantic query on its result, is a function of its inputs alone — the same under 1, 2 or 8 concurrent compilations, on a cold or a warm `CompilerApi`, in a fresh or a long-lived process; every CLR name resolves against ONE `AssemblyVisibility` = a process-constant framework index ∪ the assemblies the compilation's own `ModuleRegistry` loaded, into a load context keyed by its reference set (`ReferenceLoadContext`); the master `BuiltinRegistry` is frozen after construction and a user definition never writes a master symbol; a stdlib module global CPython makes thread-safe is read and written under one module lock; a per-event artefact (crash bundle) is created exclusively | `InProcessIsolationSweepTests` (GapDiscovery, its own CI step: corpus × seeded schedule × sharing × threads × {Analyze, Compile}, allowlist `in-process-isolation-allowlist.txt`); `ProcessWideResolutionScanTests` (Roslyn symbol census of every process-wide resolution/load site: roster + drain-on-fix allowlist, literal anchors); `StaticStateConformanceTests` rule 2b (static `ConcurrentDictionary`/`Lazy`/`ConditionalWeakTable` rostered with a key-purity reason; static auto-properties included); `TpaNamespaceGateTests`, `RegistryIsolationTests`, `CompileServerE2ETests.RequestSequence_NoRef_Ref_NoRef…`, `WarmInstanceParityTests` (Lsp) + `MasterRegistryImmutabilityTests` (master symbols frozen, key-set snapshot); `StdlibModuleGlobalRosterTests` (written-after-init census) + `StdlibModuleGlobalConcurrencyTests` (CPython's process-wide model at 1/2/8 threads); `CrashBundleExclusivityTests`; `CliStaticStateConformanceTests` | #2250 | ## Policy riders diff --git a/docs/stdlib/numpy.md b/docs/stdlib/numpy.md index d265cba76..cb32fbc29 100644 --- a/docs/stdlib/numpy.md +++ b/docs/stdlib/numpy.md @@ -680,7 +680,7 @@ import numpy.random ### `numpy.random.seed(seed: int)` -Seed the thread-local random number generator with *seed*. +Seed the process-wide random number generator with *seed*. **Parameters:** diff --git a/src/Sharpy.Cli.Tests/CliStaticStateConformanceTests.cs b/src/Sharpy.Cli.Tests/CliStaticStateConformanceTests.cs new file mode 100644 index 000000000..71171fd14 --- /dev/null +++ b/src/Sharpy.Cli.Tests/CliStaticStateConformanceTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using Xunit; + +namespace Sharpy.Cli.Tests; + +/// +/// The Cli twin of StaticStateConformanceTests (#1036, #2250): the compile server serves many +/// requests from one process, so a mutable static in the Cli assembly is state one request can leave +/// for the next. Every static field of sharpyc must be readonly/const, and a +/// static readonly mutable collection or cache (List, Dictionary, HashSet, +/// ConcurrentDictionary, Lazy, ConditionalWeakTable) is equally state — unless it +/// is a row whose justification says why it cannot carry one request's answer +/// into another. A stale row (its field no longer flagged) is red. +/// +public class CliStaticStateConformanceTests +{ + /// (declaring type full name, field name) → why this static cannot leak across requests. + private static readonly IReadOnlyDictionary<(string Type, string Field), string> Roster = + new Dictionary<(string, string), string> + { + [("Sharpy.Cli.CliHelpers", "ShowDiagnosticProvenance")] = + "a rendering switch set from --verbose by every command handler from its own parse result " + + "before any rendering, so each command (and each compile-server request, which runs under " + + "the server's _compileLock) writes it before it reads it; it never carries a compile's answer (#2250)", + }; + + [Fact] + public void CliAssembly_HasNoUnrosteredMutableStaticState() + { + var violations = FlaggedStatics(typeof(CliHelpers).Assembly) + .Where(f => !Roster.ContainsKey(f.Key)) + .Select(f => f.Description) + .OrderBy(v => v, StringComparer.Ordinal) + .ToList(); + + Assert.True(violations.Count == 0, + "Unrostered mutable static state in the Cli assembly (a compile-server request can read what an " + + "earlier request left). Return the value instead, or add a Roster row with the reason it cannot " + + "leak across requests:\n " + string.Join("\n ", violations)); + } + + [Fact] + public void Roster_HasNoStaleRows() + { + var flagged = FlaggedStatics(typeof(CliHelpers).Assembly).Select(f => f.Key).ToHashSet(); + var stale = Roster.Keys.Where(k => !flagged.Contains(k)) + .Select(k => $"{k.Type}.{k.Field}") + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + + Assert.True(stale.Count == 0, "Stale Roster rows (field no longer flagged) — delete them:\n " + + string.Join("\n ", stale)); + } + + /// Positive control: the scan flags a mutable static and a static cache in a known type. + [Fact] + public void Scan_FlagsAMutableStaticAndAStaticCache_PositiveControl() + { + var flagged = FlaggedStatics(typeof(CliStaticStateConformanceTests).Assembly) + .Select(f => f.Key) + .ToHashSet(); + + Assert.Contains((typeof(PositiveControl).FullName!, nameof(PositiveControl.Counter)), flagged); + Assert.Contains((typeof(PositiveControl).FullName!, nameof(PositiveControl.Cache)), flagged); + Assert.DoesNotContain((typeof(PositiveControl).FullName!, nameof(PositiveControl.Constant)), flagged); + } + + private static class PositiveControl + { + public static int Counter = 1; // the field IS the probe: a non-readonly static the scan must flag + public static readonly ConcurrentDictionary Cache = new(); + public static readonly string Constant = "init-once"; + } + + private static IEnumerable<((string Type, string Field) Key, string Description)> FlaggedStatics(Assembly assembly) + { + foreach (var type in assembly.GetTypes()) + { + if (IsCompilerGenerated(type)) + continue; + + foreach (var field in type.GetFields( + BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) + { + if (IsCompilerGenerated(field) || field.IsLiteral) + continue; + + var key = (type.FullName ?? type.Name, field.Name); + if (!field.IsInitOnly) + yield return (key, $"[not readonly] {key.Item1}.{field.Name} : {field.FieldType.Name}"); + else if (IsMutableCollectionOrCache(field.FieldType)) + yield return (key, $"[mutable static collection/cache] {key.Item1}.{field.Name} : {field.FieldType.Name}"); + } + } + } + + private static bool IsMutableCollectionOrCache(Type type) + { + if (!type.IsGenericType) + return false; + + var def = type.GetGenericTypeDefinition(); + return def == typeof(List<>) + || def == typeof(Dictionary<,>) + || def == typeof(HashSet<>) + || def == typeof(ConcurrentDictionary<,>) + || def == typeof(Lazy<>) + || def == typeof(ConditionalWeakTable<,>); + } + + private static bool IsCompilerGenerated(MemberInfo member) => + member.GetCustomAttribute() != null + || member.Name.IndexOf('<') >= 0; +} diff --git a/src/Sharpy.Cli.Tests/E2E/CompileServerE2ETests.cs b/src/Sharpy.Cli.Tests/E2E/CompileServerE2ETests.cs index 014db8d82..ecedfaf73 100644 --- a/src/Sharpy.Cli.Tests/E2E/CompileServerE2ETests.cs +++ b/src/Sharpy.Cli.Tests/E2E/CompileServerE2ETests.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; using FluentAssertions; using Sharpy.Cli.Services; using Xunit; @@ -169,6 +171,133 @@ public void ServerRoute_Verbose_PrintsTheServerSummary_OnBothArms(string command } } + /// + /// Deterministic base reproductions of the server request sequence, keyed by request; the value + /// is the server's observed at base. Each row drains in P54 Phase 2 + /// (#2250). + /// + private static readonly IReadOnlyDictionary RequestSequenceKnownRed = new Dictionary + { + // Empty since P54 Phase 2 task 1 (#2250): request 3 answered SPY0908(CS0400) @ 486c9a88a + // because request 2's reference stayed visible to the process-wide scans; with every CLR name + // resolved against the request's own AssemblyVisibility it answers SPY0300, exit 1, as a + // fresh process does — the row reported stale and drained. + }; + + /// + /// P54 (#2250) cell 4: a compile is a pure function of its inputs, so ONE server answering + /// run main.spy (no -r) → run -r Tablegreeter.dll main.spy → run main.spy + /// answers each request as a fresh sharpyc process answers the same command: exit code and + /// diagnostic code set (and the program's line when it runs). Requests 1–2 are the positive + /// controls (SPY0300; runs); request 3 was a row at base. + /// Every client is a child process, so the library is never loaded into the test host. + /// + [Fact] + public void RequestSequence_NoRef_Ref_NoRef_AnswersAsThreeFreshProcesses() + { + var library = new ReferenceLibraryFixture(); + _ws.WriteSpy("from Tablegreeter import Greeter\n\ndef main():\n print(Greeter().greet())\n"); + var requests = new (string Id, string[] Args)[] + { + ("1: run main.spy", new[] { "run", "main.spy" }), + ("2: run -r Tablegreeter.dll main.spy", new[] { "run", "-r", library.DllPath, "main.spy" }), + ("3: run main.spy", new[] { "run", "main.spy" }), + }; + + var pipeName = "spy-seq-" + Guid.NewGuid().ToString("N")[..8]; + var failures = new List(); + var oracles = new List(); + using var server = StartServer(pipeName); + try + { + WaitUntilListening(pipeName, server); + + foreach (var (id, args) in requests) + { + var viaServer = ExecCli(args.Append($"--server={pipeName}").ToArray()); + var fresh = ExecCli(args); + var served = RunFingerprint(viaServer); + var oracle = RunFingerprint(fresh); + oracles.Add(oracle); + var report = $"server: {served}\n{viaServer.StdOut}{viaServer.StdErr}\nfresh: {oracle}\n{fresh.StdOut}{fresh.StdErr}"; + + viaServer.StdErr.Should().NotContain("compiling in-process", $"[{id}] the request reaches the live server"); + + if (RequestSequenceKnownRed.TryGetValue(id, out var recorded)) + { + if (served != recorded || served == oracle) + failures.Add($"[{id}] KnownRed row is stale — drain it (#2250): recorded '{recorded}'\n{report}"); + } + else if (served != oracle) + { + failures.Add($"[{id}] the server answers unlike a fresh process\n{report}"); + } + } + } + finally + { + TryShutdown(pipeName); + StopServer(server); + } + + // The fresh-process oracle is what the issue says it is, so parity is not vacuous: no -r is + // an unresolved import, -r runs the program. + oracles.Should().Equal("exit 1 | SPY0300", "exit 0 | - | hello from lib", "exit 1 | SPY0300"); + failures.Should().BeEmpty(string.Join("\n\n", failures)); + } + + /// + /// exit N | sorted distinct diagnostic codes (a SPY0908 spelled with the C# error it wraps) | + /// the program's line when it ran. + /// + private static string RunFingerprint(ProcessResult result) + { + var all = result.StdOut + result.StdErr; + var codes = Regex.Matches(all, @"(?:error|warning)\[(SPY\d{4})\]: ([^\n]*)") + .Select(m => m.Groups[1].Value == "SPY0908" + ? $"SPY0908({Regex.Match(m.Groups[2].Value, @"\bCS\d{4}\b").Value})" + : m.Groups[1].Value) + .Distinct() + .OrderBy(c => c, StringComparer.Ordinal) + .ToList(); + var fingerprint = $"exit {result.ExitCode} | {(codes.Count == 0 ? "-" : string.Join(",", codes))}"; + return all.Contains("hello from lib", StringComparison.Ordinal) ? fingerprint + " | hello from lib" : fingerprint; + } + + private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); + + /// A sharpyc child process in the workspace — a fresh process per call. + private ProcessResult ExecCli(params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = _ws.Root, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + psi.ArgumentList.Add(CliDll); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + using var process = Process.Start(psi)!; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + if (!process.WaitForExit(TimeSpan.FromMinutes(2))) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException($"sharpyc {string.Join(' ', args)} did not exit within 2 minutes"); + } + + process.WaitForExit(); + return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); + } + private static string ReadShared(string path) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); diff --git a/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs b/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs new file mode 100644 index 000000000..4573a72e6 --- /dev/null +++ b/src/Sharpy.Cli.Tests/E2E/HostAssemblyVisibilityE2ETests.cs @@ -0,0 +1,107 @@ +using System.Diagnostics; +using System.Text; +using FluentAssertions; +using Xunit; + +namespace Sharpy.Cli.Tests.E2E; + +/// +/// #2250 (P54 final-verify F3), the sharpyc twin of +/// Sharpy.Compiler.Tests.Conformance.FrameworkVisibilityTests: the framework a compilation sees +/// is the shared framework plus Sharpy.Core, never sharpyc's own application assemblies +/// (System.CommandLine, Microsoft.CodeAnalysis*, Microsoft.Extensions.*). sharpyc's trusted-platform +/// list carries those packages, so this host discriminates a name filter from the shared-framework +/// directory. +/// +/// +/// Measured with the built sharpyc: from microsoft.extensions.primitives import StringValues was +/// SPY0300 at base 486c9a88a and compiled, then died with FileNotFoundException, at effba7776; +/// from system.command_line import RootCommand compiled then died (exit 134, +/// FileNotFoundException System.CommandLine) at both. A refusal (SPY0301, the system.* prefix's +/// answer for a namespace that exists nowhere) is the fix by direction. The +/// -r cell is the positive control: the same import with the package given as a reference +/// compiles and runs. +/// +public class HostAssemblyVisibilityE2ETests : IDisposable +{ + private static readonly string CliDll = Path.Combine(AppContext.BaseDirectory, "sharpyc.dll"); + + private const string PrimitivesProgram = + "from microsoft.extensions.primitives import StringValues\n\n" + + "def main() -> None:\n s = StringValues(\"abc\")\n print(s.count)\n"; + + private const string CommandLineProgram = + "from system.command_line import RootCommand\n\n" + + "def main() -> None:\n r = RootCommand(\"hello\")\n print(r.description)\n"; + + private readonly TempWorkspace _ws = new(); + + public void Dispose() => _ws.Dispose(); + + /// + /// The refusal is the one a namespace that exists nowhere gets under the same prefix: SPY0300 for + /// microsoft.*, SPY0301 for system.* (whose arm maps any name without an existence + /// check — from system.no_such_namespace import Foo is SPY0301 at base 486c9a88a, measured). + /// + [Theory] + [InlineData("extensions_primitives", PrimitivesProgram, "SPY0300")] + [InlineData("command_line", CommandLineProgram, "SPY0301")] + public void HostPackageImport_WithoutReference_IsRefusedAtImport(string cell, string program, string refusal) + { + _ws.WriteSpy(program); + var result = ExecCli("run", "main.spy"); + var output = result.StdOut + result.StdErr; + + result.ExitCode.Should().Be(1, $"[{cell}] a host package is not the framework — refused, not run\n{output}"); + output.Should().Contain(refusal, $"[{cell}] refused at import\n{output}"); + output.Should().NotContain("FileNotFoundException", $"[{cell}] never a compile that dies at run\n{output}"); + } + + [Fact] + public void HostPackageImport_WithReference_CompilesAndRuns() + { + var hostCopy = Path.Combine(AppContext.BaseDirectory, "System.CommandLine.dll"); + File.Exists(hostCopy).Should().BeTrue("INCONCLUSIVE: sharpyc ships System.CommandLine next to the test host"); + var userCopy = Path.Combine(_ws.Root, "libs", "System.CommandLine.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(userCopy)!); + File.Copy(hostCopy, userCopy); + + _ws.WriteSpy(CommandLineProgram); + var result = ExecCli("run", "main.spy", "-r", userCopy); + var output = result.StdOut + result.StdErr; + + result.ExitCode.Should().Be(0, $"a host package given by reference is visible and deployed\n{output}"); + result.StdOut.Should().Contain("hello", output); + } + + private ProcessResult ExecCli(params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = _ws.Root, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + psi.ArgumentList.Add(CliDll); + foreach (var arg in args) + { + psi.ArgumentList.Add(arg); + } + + using var process = Process.Start(psi)!; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + process.WaitForExit(180_000).Should().BeTrue("the process must not hang"); + process.WaitForExit(); // flush async readers + return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); + } + + private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); +} diff --git a/src/Sharpy.Cli.Tests/E2E/TypeDefinitionInBlockDirectionTests.cs b/src/Sharpy.Cli.Tests/E2E/TypeDefinitionInBlockDirectionTests.cs new file mode 100644 index 000000000..10f5f0cf9 --- /dev/null +++ b/src/Sharpy.Cli.Tests/E2E/TypeDefinitionInBlockDirectionTests.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.Text; +using FluentAssertions; +using Sharpy.TestInfrastructure.Registry; +using Xunit; + +namespace Sharpy.Cli.Tests.E2E; + +/// +/// Direction pin for P54 (#2250) final verification F2 @ effba7776, through sharpyc run (a refusal is verified +/// with run, not emit): a type definition inside a block or a function body +/// () is REFUSED — exit 1, SPY0202 (no symbol of its own; C# has +/// no local types), SPY0340 for a module-level block — and never an internal error nor a crash bundle. +/// +/// +/// Measured with the built sharpyc: the @dataclass class str in a module-level try was refused +/// SPY0340 at 486c9a88a, became SPY0909 (PhaseViolationException on the frozen master str) plus a +/// .sharpy-crash bundle under the source directory at effba7776, and is SPY0340 + SPY0202 again here. The +/// function-local struct/interface/delegate/class texts were SPY0510 ("unrecognized statement type … was not emitted") +/// ICEs at both 486c9a88a and effba7776: the checker had bound them to the master str, so nothing refused them +/// before the emitter. +/// +public sealed class TypeDefinitionInBlockDirectionTests : IDisposable +{ + private static readonly string CliDll = Path.Combine(AppContext.BaseDirectory, "sharpyc.dll"); + + private readonly TempWorkspace _ws = new(); + + public void Dispose() => _ws.Dispose(); + + public static IEnumerable TypeDefinitionTexts() + => WarmInstanceTriggers.TypeDefinitionTexts.Select(t => new object[] { t.Name }); + + [Theory] + [MemberData(nameof(TypeDefinitionTexts))] + public void Run_RefusesTheTypeDefinition_WithoutAnInternalErrorOrACrashBundle(string name) + { + var text = WarmInstanceTriggers.TypeDefinitionTexts.Single(t => t.Name == name).Text; + var source = _ws.WriteSpy(text); + + var result = ExecCli("run", source); + var combined = result.StdOut + "\n" + result.StdErr; + + result.ExitCode.Should().Be(1, combined); + combined.Should().Contain("SPY0202", "the definition has no symbol of its own and is refused"); + if (text.StartsWith("try:", StringComparison.Ordinal)) + combined.Should().Contain("SPY0340", "the base refusal of a module-level block stands"); + combined.Should().NotContain("SPY09", "a refusal is not an internal error"); + combined.Should().NotContain("SPY0510", "nothing the checker accepted reaches the emitter unlowerable"); + // `run` writes an ICE's bundle under its temporary build directory and names it in the diagnostic (measured at + // effba7776: "A minimal-repro crash bundle was written to: …/sharpy_run_…/.sharpy-crash/…"); other doors write + // it under the source directory. + combined.Should().NotContain("crash bundle", "a refused program writes no crash bundle"); + CrashBundles(_ws.Root).Should().BeEmpty("a refused program writes no crash bundle beside its source"); + } + + /// The instrument's positive control: the crash-bundle search finds a bundle planted where sharpyc writes one. + [Fact] + public void CrashBundleSearch_FindsAPlantedBundle() + { + Directory.CreateDirectory(_ws.PathFor(Path.Combine("bin", "Debug", "net10.0", ".sharpy-crash", "20261015-101155-079-67735"))); + CrashBundles(_ws.Root).Should().ContainSingle(); + } + + private static string[] CrashBundles(string root) + => Directory.GetDirectories(root, ".sharpy-crash", SearchOption.AllDirectories) + .SelectMany(Directory.GetDirectories) + .ToArray(); + + private ProcessResult ExecCli(params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "dotnet", + WorkingDirectory = _ws.Root, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + psi.ArgumentList.Add(CliDll); + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + using var process = Process.Start(psi)!; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + process.WaitForExit(180_000).Should().BeTrue("the CLI process must not hang"); + process.WaitForExit(); + return new ProcessResult(process.ExitCode, stdout.ToString(), stderr.ToString()); + } + + private sealed record ProcessResult(int ExitCode, string StdOut, string StdErr); +} diff --git a/src/Sharpy.Cli.Tests/Sharpy.Cli.Tests.csproj b/src/Sharpy.Cli.Tests/Sharpy.Cli.Tests.csproj index 024d21306..12b1fc2b4 100644 --- a/src/Sharpy.Cli.Tests/Sharpy.Cli.Tests.csproj +++ b/src/Sharpy.Cli.Tests/Sharpy.Cli.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Sharpy.Cli/CliHelpers.cs b/src/Sharpy.Cli/CliHelpers.cs index 2bd62e277..de593e1aa 100644 --- a/src/Sharpy.Cli/CliHelpers.cs +++ b/src/Sharpy.Cli/CliHelpers.cs @@ -70,14 +70,6 @@ internal static int MapFailureExitCode(IEnumerable diagnosti /// internal static bool ShowDiagnosticProvenance; - /// - /// Exit code produced by the most recent failed - /// call, mapped from its diagnostics (see ). Callers that treat a - /// null CompileResult as failure read this to propagate the ICE (3) / escaped-C# (2) / - /// ordinary-error (1) distinction. Defaults to . - /// - internal static int LastFailureExitCode = ExitCompileError; - internal static CompilerApi CreateCompilerApi(ICompilerLogger logger) { return new CompilerApi(logger, GetDefaultReferences()); diff --git a/src/Sharpy.Cli/Commands/BuildCommand.cs b/src/Sharpy.Cli/Commands/BuildCommand.cs index 665f3f072..5c72acc36 100644 --- a/src/Sharpy.Cli/Commands/BuildCommand.cs +++ b/src/Sharpy.Cli/Commands/BuildCommand.cs @@ -96,8 +96,8 @@ internal static void Configure(RootCommand root, GlobalOptions globals) } } - var compileResult = CompileToBinary(input, type, output, reference, modulePath, logger, metricsFormat, metricsOutput, warnAsError, nowarn, maxErrors, configuration: "Debug", features: features); - return compileResult == null ? CliHelpers.LastFailureExitCode : 0; + var compileResult = CompileToBinary(input, type, output, reference, modulePath, logger, metricsFormat, metricsOutput, out var failureExitCode, warnAsError, nowarn, maxErrors, configuration: "Debug", features: features); + return compileResult == null ? failureExitCode : 0; }); root.Subcommands.Add(command); @@ -105,8 +105,11 @@ internal static void Configure(RootCommand root, GlobalOptions globals) /// /// Compiles a Sharpy source file to a binary. Returns the - /// on success, or null if compilation or assembly generation failed (the - /// caller should treat a null result as exit code 1). + /// on success, or null if compilation or assembly generation failed; then + /// carries the ICE (3) / escaped-C# (2) / ordinary-error (1) + /// distinction mapped from this call's own diagnostics (). + /// It is a return value, not a process global read after the fact: a long-lived process + /// (the compile server) must never answer one request with another's exit code (#2250). /// internal static CompileResult? CompileToBinary( FileInfo inputFile, @@ -117,6 +120,7 @@ internal static void Configure(RootCommand root, GlobalOptions globals) ICompilerLogger logger, string? metricsFormat, FileInfo? metricsOutput, + out int failureExitCode, bool warnAsError = false, string? nowarn = null, int? maxErrors = null, @@ -128,9 +132,9 @@ internal static void Configure(RootCommand root, GlobalOptions globals) // a module path that does not exist is an error rather than ignored (#2173). A project // reference never reaches here: the commands refuse it from their option table // (CommandOptionEffects.Refuse) and the server refuses it from the request. + failureExitCode = CliHelpers.ExitCompileError; if (!CliHelpers.ValidateModulePaths(modulePaths)) { - CliHelpers.LastFailureExitCode = CliHelpers.ExitCompileError; return null; } @@ -188,7 +192,7 @@ internal static void Configure(RootCommand root, GlobalOptions globals) // import resolution, so handing the entry buffer to every diagnostic made a sibling // file's parse error underline an innocent line of the entry file (#1437). CliHelpers.RenderDiagnosticsFromFiles(result.Diagnostics.Where(d => d.IsError), Console.Error, inputFile.FullName); - CliHelpers.LastFailureExitCode = CliHelpers.MapFailureExitCode(result.Diagnostics); + failureExitCode = CliHelpers.MapFailureExitCode(result.Diagnostics); return null; } @@ -209,7 +213,7 @@ internal static void Configure(RootCommand root, GlobalOptions globals) { // An exception here (not surfaced as a diagnostic) is itself an internal error. Console.Error.WriteLine($"Unexpected error: {ex.Message}"); - CliHelpers.LastFailureExitCode = CliHelpers.ExitInternalError; + failureExitCode = CliHelpers.ExitInternalError; return null; } } diff --git a/src/Sharpy.Cli/Commands/CompileCommand.cs b/src/Sharpy.Cli/Commands/CompileCommand.cs index f7c52563c..f3a360005 100644 --- a/src/Sharpy.Cli/Commands/CompileCommand.cs +++ b/src/Sharpy.Cli/Commands/CompileCommand.cs @@ -165,11 +165,11 @@ static int CompileSingleFile( var compileResult = BuildCommand.CompileToBinary( inputFile, outputType, new FileInfo(outputPath), references, modulePaths, - logger, metricsFormat, metricsOutput, warnAsError, nowarn, maxErrors, configuration, features); + logger, metricsFormat, metricsOutput, out var failureExitCode, warnAsError, nowarn, maxErrors, configuration, features); if (compileResult == null) { - return CliHelpers.LastFailureExitCode; + return failureExitCode; } // The deployment seed's user half: the `-r` list spelled exactly as CompileToBinary handed it diff --git a/src/Sharpy.Cli/Commands/RunCommand.cs b/src/Sharpy.Cli/Commands/RunCommand.cs index d34446fa9..d84976774 100644 --- a/src/Sharpy.Cli/Commands/RunCommand.cs +++ b/src/Sharpy.Cli/Commands/RunCommand.cs @@ -204,10 +204,10 @@ static int HandleRunCommand( } else { - var compileResult = BuildCommand.CompileToBinary(inputFile, "exe", new FileInfo(outputPath), references, modulePaths, logger, metricsFormat, metricsOutput, warnAsError, nowarn, maxErrors, features: features, namespaceName: namespaceName); + var compileResult = BuildCommand.CompileToBinary(inputFile, "exe", new FileInfo(outputPath), references, modulePaths, logger, metricsFormat, metricsOutput, out var failureExitCode, warnAsError, nowarn, maxErrors, features: features, namespaceName: namespaceName); if (compileResult == null) { - return CliHelpers.LastFailureExitCode; + return failureExitCode; } usedAssemblyPaths = compileResult.UsedAssemblyPaths; diff --git a/src/Sharpy.Cli/Services/CompileServer.cs b/src/Sharpy.Cli/Services/CompileServer.cs index b582e8b14..c071f5d47 100644 --- a/src/Sharpy.Cli/Services/CompileServer.cs +++ b/src/Sharpy.Cli/Services/CompileServer.cs @@ -384,6 +384,7 @@ private static (int ExitCode, ProjectCompilationMetrics? Metrics, IReadOnlySet +/// The framework a compilation sees is the SHARED FRAMEWORK (the assemblies Microsoft.NETCore.App +/// ships, in the runtime directory CoreLib was loaded from) plus Sharpy.Core — never the host's +/// own application assemblies (#2250, P54 final-verify F3). The contract: a compile is a function of its +/// inputs, so sharpyc, the LSP and a test host — whose trusted-platform lists carry different +/// deps.json packages — must see one framework. +/// +/// +/// At effba7776 the framework set was every trusted-platform assembly NAMED System/System.*/ +/// Microsoft.*, so this test host saw Microsoft.CodeAnalysis, Microsoft.Extensions.Primitives, +/// System.Reactive and Microsoft.TestPlatform.* as framework, sharpyc saw System.CommandLine, and the LSP +/// did not. from microsoft.extensions.primitives import StringValues was SPY0300 at base +/// 486c9a88a and compiled, then died with FileNotFoundException at run, at effba7776 (measured, built +/// sharpyc); system.command_line and microsoft.code_analysis.c_sharp compiled then crashed +/// at both (measured: exit 134, FileNotFoundException). +/// Every negative row below is in THIS host's trusted-platform list (asserted, so the cell +/// discriminates a name filter from a directory filter) except System.CommandLine, which is not a +/// dependency of this test project — its discriminating twin is the sharpyc E2E in +/// Sharpy.Cli.Tests.E2E.HostAssemblyVisibilityE2ETests. +/// +[Trait("Category", "Conformance")] +public class FrameworkVisibilityTests : IDisposable +{ + /// Host packages present in this test host's trusted-platform list — each must NOT be framework. + private static readonly string[] HostPackagesInThisHost = + { + "Microsoft.CodeAnalysis", + "Microsoft.CodeAnalysis.CSharp", + "Microsoft.Extensions.Primitives", + "Microsoft.Extensions.FileSystemGlobbing", + "System.Reactive", + "System.Composition.Hosting", + "Microsoft.TestPlatform.CoreUtilities", + "xunit.core", + }; + + /// Shared-framework assemblies — each MUST be framework (positive control). + private static readonly string[] SharedFrameworkRows = + { + "System.Private.CoreLib", + "System.Runtime", + "System.Collections", + "System.Text.RegularExpressions", + "Microsoft.Win32.Primitives", + "netstandard", + }; + + private readonly ITestOutputHelper _output; + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"sharpy_p54fw_{Guid.NewGuid():N}"); + + public FrameworkVisibilityTests(ITestOutputHelper output) + { + _output = output; + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try + { Directory.Delete(_dir, recursive: true); } + catch { /* best effort */ } + } + + private static Dictionary TrustedPlatformPaths() + { + var byName = new Dictionary(StringComparer.Ordinal); + foreach (var path in ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + byName.TryAdd(Path.GetFileNameWithoutExtension(path), path); + } + return byName; + } + + [Fact] + public void FrameworkSet_IsTheSharedFrameworkPlusSharpyCore() + { + var runtimeDirectory = RuntimeEnvironment.GetRuntimeDirectory(); + var tpa = TrustedPlatformPaths(); + var framework = AssemblyVisibility.FrameworkOnly.Assemblies + .Select(a => AssemblyVisibility.SafeSimpleName(a) ?? "") + .ToList(); + _output.WriteLine($"runtime directory: {runtimeDirectory}; framework set: {framework.Count} assemblies; TPA: {tpa.Count}"); + + // Discrimination precondition: the host packages ARE trusted-platform here, so a name filter + // over the trusted-platform list would admit them. + var missing = HostPackagesInThisHost.Where(n => !tpa.ContainsKey(n)).ToList(); + missing.Should().BeEmpty("INCONCLUSIVE: these host packages are not in this test host's trusted-platform list, so their rows discriminate nothing"); + + var failures = new List(); + + // Subset: every framework assembly is a shared-framework file, or Sharpy.Core. + foreach (var name in framework) + { + if (name != "Sharpy.Core" && !File.Exists(Path.Combine(runtimeDirectory, name + ".dll"))) + failures.Add($"[subset] '{name}' is in the framework set but is not a shared-framework assembly"); + } + + foreach (var name in SharedFrameworkRows) + { + if (!framework.Contains(name)) + failures.Add($"[positive] shared-framework '{name}' is missing from the framework set"); + } + if (!framework.Contains("Sharpy.Core")) + failures.Add("[positive] Sharpy.Core is missing from the framework set"); + + foreach (var name in HostPackagesInThisHost.Append("System.CommandLine").Append("Sharpy.Compiler")) + { + if (framework.Contains(name)) + failures.Add($"[negative] host assembly '{name}' is in the framework set"); + if (AssemblyVisibility.FrameworkOnly.FindAssembly(name) != null) + failures.Add($"[negative] FrameworkOnly.FindAssembly('{name}') answers the host's copy"); + } + + foreach (var ns in new[] { "Microsoft.CodeAnalysis", "Microsoft.Extensions.Primitives", "System.Reactive.Linq", "System.CommandLine", "Xunit" }) + { + if (AssemblyVisibility.FrameworkOnly.NamespaceExists(ns)) + failures.Add($"[negative] host namespace '{ns}' is visible with no reference"); + } + foreach (var ns in new[] { "System.Collections.Generic", "System.Text", "Microsoft.Win32", "Sharpy" }) + { + if (!AssemblyVisibility.FrameworkOnly.NamespaceExists(ns)) + failures.Add($"[positive] framework namespace '{ns}' is not visible"); + } + + failures.Should().BeEmpty(string.Join("\n", failures)); + } + + /// + /// An import of a host package with no reference is refused at import — never a compile that dies + /// at run with FileNotFoundException — with the SAME refusal an import of a namespace that exists + /// nowhere gets under that prefix: SPY0300 for a non-system prefix (the base answer for + /// microsoft.extensions.primitives), SPY0301 for system.*, whose arm maps any name + /// without an existence check (from system.no_such_namespace import Foo is SPY0301 at base + /// 486c9a88a, measured — the "control.nowhere" rows). Positive control: a shared-framework namespace + /// import compiles in the same registry shape. + /// + [Theory] + [InlineData("extensions_primitives", "from microsoft.extensions.primitives import StringValues\n\ndef main() -> None:\n s = StringValues(\"abc\")\n print(s.count)\n", "SPY0300")] + [InlineData("code_analysis", "from microsoft.code_analysis.c_sharp import CSharpSyntaxTree\n\ndef main() -> None:\n t = CSharpSyntaxTree.parse_text(\"class C {}\")\n print(t.length)\n", "SPY0300")] + [InlineData("control.nowhere_microsoft", "from microsoft.no_such_namespace import Foo\n\ndef main() -> None:\n print(Foo())\n", "SPY0300")] + [InlineData("reactive", "from system.reactive.linq import Observable\n\ndef main() -> None:\n x = Observable.range(1, 2)\n print(x is not None)\n", "SPY0301")] + [InlineData("command_line", "from system.command_line import RootCommand\n\ndef main() -> None:\n r = RootCommand(\"hello\")\n print(r.description)\n", "SPY0301")] + [InlineData("control.nowhere_system", "from system.no_such_namespace import Foo\n\ndef main() -> None:\n print(Foo())\n", "SPY0301")] + [InlineData("control.system_text", "from system.text import StringBuilder\n\ndef main() -> None:\n sb = StringBuilder()\n sb.append(\"x\")\n print(sb.to_string())\n", null)] + public void HostPackageImport_WithoutReference_IsRefusedAtImport(string cell, string source, string? refusal) + { + var dir = Path.Combine(_dir, cell); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "main.spy"); + File.WriteAllText(mainPath, source); + + // sharpyc's shape: Sharpy.Core and Sharpy.Stdlib are the default references. + var defaultReferences = new[] { "Sharpy.Core.dll", "Sharpy.Stdlib.dll" } + .Select(n => Path.Combine(AppContext.BaseDirectory, n)) + .ToArray(); + var result = new CompilerApi().Compile(source, + CompilerOptionsFactory.ForCli(references: defaultReferences, + assemblyName: "Fw_" + cell.Replace('.', '_'), + outputAssemblyPath: Path.Combine(dir, "out", "main.dll")), + mainPath); + var report = string.Join("\n", result.Diagnostics.Select(d => $" {d.Code} {d.Line}:{d.Column} {d.Message}")); + _output.WriteLine($"[{cell}] success={result.Success}\n{report}"); + + if (refusal == null) + { + result.Success.Should().BeTrue($"[{cell}] a shared-framework import compiles (positive control)\n{report}"); + return; + } + + result.Success.Should().BeFalse($"[{cell}] a host package is not the framework\n{report}"); + var errors = result.Diagnostics.Where(d => d.IsError).ToList(); + errors.Should().Contain(d => d.Code == refusal && d.Line == 1, + $"[{cell}] refused at the import with {refusal}\n{report}"); + errors.Should().NotContain(d => d.Code == DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError, + $"[{cell}] refused by the front end, not by the C# compiler\n{report}"); + } + + /// + /// A user reference whose simple name is a HOST assembly's loads the user's file into the + /// registry's reference context — not the host's copy (F3 (d)). Positive control: a reference whose + /// simple name is Sharpy.Core's keeps the framework copy (the one identity the compiler was built + /// against). + /// + [Fact] + public void UserReference_NamedLikeAHostAssembly_IsTheUsersFile() + { + var tpa = TrustedPlatformPaths(); + const string hostName = "Microsoft.Extensions.FileSystemGlobbing"; + tpa.Should().ContainKey(hostName, "INCONCLUSIVE: the host assembly must be in this host's trusted-platform list"); + + var userCopy = Path.Combine(_dir, "user", hostName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(userCopy)!); + File.Copy(tpa[hostName], userCopy); + + var coreLocation = typeof(SharpyRT::Sharpy.Builtins).Assembly.Location; + var coreCopy = Path.Combine(_dir, "core", Path.GetFileName(coreLocation)); + Directory.CreateDirectory(Path.GetDirectoryName(coreCopy)!); + File.Copy(coreLocation, coreCopy); + + var registry = new ModuleRegistry(); + registry.LoadReference(userCopy).Should().BeTrue(string.Join("\n", registry.Diagnostics.GetAll())); + registry.LoadReference(coreCopy).Should().BeTrue(string.Join("\n", registry.Diagnostics.GetAll())); + + var user = registry.LoadedAssemblies.Single(a => a.GetName().Name == hostName); + _output.WriteLine($"user reference bound to: {user.Location} (context {AssemblyLoadContext.GetLoadContext(user)?.Name})"); + Path.GetFullPath(user.Location).Should().Be(Path.GetFullPath(userCopy), + "a user reference named like a host assembly is the user's file, not the host's copy"); + AssemblyLoadContext.GetLoadContext(user).Should().NotBe(AssemblyLoadContext.Default, + "it loads into the registry's reference context"); + registry.Visibility.FindAssembly(hostName).Should().BeSameAs(user, "the registry sees its own copy"); + registry.Visibility.OwnAssemblies.Should().Contain(user); + + var core = registry.LoadedAssemblies.Single(a => a.GetName().Name == "Sharpy.Core"); + core.Should().BeSameAs(typeof(SharpyRT::Sharpy.Builtins).Assembly, + "Sharpy.Core keeps the framework copy (positive control: the framework arm still fires)"); + } +} diff --git a/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs new file mode 100644 index 000000000..551c8c936 --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationCorpus.cs @@ -0,0 +1,186 @@ +using Sharpy.Compiler.Tests.Fuzz; +using Sharpy.TestInfrastructure.Integration; + +namespace Sharpy.Compiler.Tests.Conformance; + +/// One program of the in-process isolation corpus: a stable id (the allowlist key), the +/// file name the compile names it by, and its source. +internal sealed record IsolationProgram(string Id, string FileName, string Source); + +/// The two operations the sweep drives on every instance kind. +internal enum IsolationOperation +{ + /// Lexer → parser → semantic → validation; no code generation. + Analyze, + + /// The whole pipeline through the C# → IL emit (the stage SPY0908 is raised at). + Compile, +} + +/// One step of a thread's schedule. +internal readonly record struct IsolationStep(IsolationProgram Program, IsolationOperation Operation); + +/// +/// The corpus and the schedule generator of (P54, #2250). +/// +/// The roster is literal: each named entry is a program a #2250 cell was measured on, or +/// one that drives a process-wide resolution path the plan's census names — +/// +/// fuzz/seed2025_iter18 — cell 1: SemanticPropertyTests.Compilation_IsDeterministic +/// seed 2025, iteration 18, regenerated from (the generator is seeded, so +/// the text is a pure function of the seed; refuses a regenerated text that no +/// longer carries the measured program's first line). +/// the three P47 stems of cells 2–3 (fstrings/format_iformattable_class_1988, +/// fstrings/format_no_dunder_format_refused_1988, deferred_overload_annotated_pin_1589), +/// read from the fixture corpus. +/// tpa/drive_info — from system.io import DriveInfo: the first use of +/// System.IO loads a trusted-platform assembly through the process-wide gate. +/// decorators/bracket_attr_import_used_1429 — a CLR attribute resolved through an +/// imported namespace (the attribute resolver's static presence cache). +/// +/// plus a stride- sample of the single-file fixture corpus in ordinal +/// test-name order (). The main.spy + Greeter library pair of +/// cell 4 is lane "server"'s (CompileServerE2ETests, RegistryIsolationTests) — building a +/// library here would cost a compile per run for a cell another test pins. +/// +/// Schedules are seeded: thread t runs drawn from +/// new Random(ThreadSeeds[t]) — every named program under both operations plus +/// draws from the sample, shuffled. The seeds are literals, so a +/// schedule is the same in every mode, every repetition and every process; no unseeded +/// and no GetHashCode. +/// +internal static class InProcessIsolationCorpus +{ + /// Every 16th single-file fixture (ordinal test-name order) joins the corpus. + internal const int SampleStride = 16; + + /// Sample draws per thread schedule, on top of the named roster's 12 steps. + internal const int SampleDrawsPerThread = 36; + + /// The fuzz seed and iteration of cell 1 (P41 P5.1, @ af6f30901). + internal const int Cell1FuzzSeed = 2025; + + internal const int Cell1FuzzIteration = 18; + + /// The first line of the measured cell-1 program (#2250's issue text). + internal const string Cell1FirstLine = "from snake_case import __dunder__"; + + /// One literal seed per thread index; a schedule never depends on anything else. + internal static readonly int[] ThreadSeeds = + { + 2250_01, 2250_02, 2250_03, 2250_04, 2250_05, 2250_06, 2250_07, 2250_08, + }; + + /// The roster's fixture entries (test name = path under TestFixtures, no extension). + private static readonly string[] NamedFixtureStems = + { + "fstrings/format_iformattable_class_1988", + "fstrings/format_no_dunder_format_refused_1988", + "deferred_overload_annotated_pin_1589", + "decorators/bracket_attr_import_used_1429", + }; + + internal const string DriveInfoProgramId = "tpa/drive_info"; + + internal const string DriveInfoSource = + "from system.io import DriveInfo\n" + + "\n" + + "\n" + + "def main() -> None:\n" + + " drive = DriveInfo(\"/\")\n" + + " print(drive.name)\n"; + + private static readonly Lazy> NamedValue = new(BuildNamed); + + private static readonly Lazy> SampleValue = new(BuildSample); + + /// The literal roster, in roster order. + internal static IReadOnlyList Named => NamedValue.Value; + + /// The stride sample of the single-file fixture corpus (named entries excluded). + internal static IReadOnlyList Sample => SampleValue.Value; + + /// + /// Thread 's schedule: every named program under both operations + /// plus (program, operation) draws from the sample, shuffled — + /// all from new Random(ThreadSeeds[threadIndex]). + /// + internal static IReadOnlyList Schedule(int threadIndex) + { + var random = new Random(ThreadSeeds[threadIndex]); + var steps = new List(); + foreach (var program in Named) + { + steps.Add(new IsolationStep(program, IsolationOperation.Analyze)); + steps.Add(new IsolationStep(program, IsolationOperation.Compile)); + } + + for (var i = 0; i < SampleDrawsPerThread; i++) + { + var program = Sample[random.Next(Sample.Count)]; + var operation = random.Next(2) == 0 ? IsolationOperation.Analyze : IsolationOperation.Compile; + steps.Add(new IsolationStep(program, operation)); + } + + // Fisher–Yates on the same seeded generator. + for (var i = steps.Count - 1; i > 0; i--) + { + var j = random.Next(i + 1); + (steps[i], steps[j]) = (steps[j], steps[i]); + } + + return steps; + } + + private static IReadOnlyList BuildNamed() + { + var programs = new List { RegenerateCell1Program() }; + var fixtures = SingleFileFixtures().ToDictionary(f => f.TestName, StringComparer.Ordinal); + foreach (var stem in NamedFixtureStems) + { + if (!fixtures.TryGetValue(stem, out var fixture)) + throw new InvalidOperationException($"The isolation roster names fixture '{stem}', which no longer exists as a single-file fixture."); + programs.Add(FromFixture(fixture)); + } + + programs.Add(new IsolationProgram(DriveInfoProgramId, "drive_info.spy", DriveInfoSource)); + return programs; + } + + /// Cell 1's program: the fuzzer's -th draw (0-based) for . + internal static IsolationProgram RegenerateCell1Program() + { + var fuzzer = new SharpyFuzzer(Cell1FuzzSeed); + var source = ""; + for (var i = 0; i <= Cell1FuzzIteration; i++) + source = fuzzer.GenerateValidLooking(); + if (!source.StartsWith(Cell1FirstLine, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"SharpyFuzzer({Cell1FuzzSeed}) iteration {Cell1FuzzIteration} no longer regenerates #2250's cell-1 program " + + $"(expected it to start with `{Cell1FirstLine}`); the generator changed — pin the measured text instead:\n{source}"); + return new IsolationProgram("fuzz/seed2025_iter18", "fuzz_det.spy", source); + } + + private static IReadOnlyList BuildSample() + { + var named = new HashSet(NamedFixtureStems, StringComparer.Ordinal); + return SingleFileFixtures() + .Where(f => !named.Contains(f.TestName)) + .OrderBy(f => f.TestName, StringComparer.Ordinal) + .Where((_, index) => index % SampleStride == 0) + .Select(FromFixture) + .ToList(); + } + + /// + /// Single-file, non-skipped fixtures WITHOUT a .features sidecar: the per-thread + /// Compiler binds its options (and so its feature set) at construction, so a feature-gated + /// fixture cannot share an instance with the rest of a schedule. + /// + private static IEnumerable SingleFileFixtures() + => FixtureDiscoveryHelper.DiscoverFixturesFrom(FixtureRoots.CompilerTests) + .Where(f => !f.IsMultiFile && f.Features.Count == 0); + + private static IsolationProgram FromFixture(TestFixtureInfo fixture) + => new(fixture.TestName, Path.GetFileName(fixture.SpyFilePath), File.ReadAllText(fixture.SpyFilePath)); +} diff --git a/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs new file mode 100644 index 000000000..3ce643ac6 --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Conformance/InProcessIsolationSweepTests.cs @@ -0,0 +1,601 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; +using Sharpy.Compiler.Diagnostics; +using Sharpy.Compiler.Logging; +using Sharpy.TestInfrastructure; +using Sharpy.TestInfrastructure.Integration; +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Compiler.Tests.Conformance; + +/// +/// P54 (#2250) — a compile is a function of its inputs alone: the same under 1, 2 or 8 +/// concurrent compilations, on a cold or a warm instance. The interleaving generator: corpus +/// () × seeded per-thread schedules × instance sharing × +/// thread count × operation, each observation compared with an oracle. +/// +/// Sharing. compiler-per-thread — each thread builds one +/// (cell 1's instance type; one module registry, built in its constructor, for the whole schedule) +/// and runs its whole schedule on it; api-per-thread — each thread builds one +/// (the LSP / harness shape: a fresh per +/// Compile, a cached analysis context per reference set); both reference Sharpy.Core; +/// api-shared — ONE serves every thread (the LSP server and the +/// compile server hold one for the life of the process). +/// +/// Operations. analyze (, +/// ) and compile (the production pipeline through the +/// C# → IL emit: names a per-call temp directory, the +/// image is emitted in memory and written only for a successful build, then deleted). The +/// code-action Extract and applied-text Analyze operations of the plan's matrix are lane +/// "warm"'s LSP twin (Sharpy.Lsp.Tests WarmInstanceParityTests) — this project does +/// not reference the LSP handlers. +/// +/// Oracle and fingerprint. Before any concurrent phase, every (instance kind, program, +/// operation) a schedule names runs ALONE on the test thread on a FRESH instance. A fingerprint is +/// success + the sorted multiset of diagnostic (code, line, col) + the used-assembly set +/// where the operation exposes one (compile on both kinds — +/// and the 's registry — and analyze on ; +/// exposes none, so analyze on a compares +/// the first two only) + the type of an escaped exception. A cancelled operation (SPY0901, past +/// ) is skipped, never compared. +/// +/// Buckets (): crash — an exception escaped where the oracle +/// returned; order — on ONE thread the warm instance's answer differs from the fresh one's (a +/// warm-sequential divergence: no concurrency is involved); drift — under 2 or 8 threads the +/// answer differs from the solo oracle. +/// +/// Modes. (Category=GapDiscovery, +/// its own CI step) = 8 threads × R=8 repetitions of the same schedules; +/// (regular suite) = threads {1, 2} × R=1, or the whole matrix {1, 2, 8} × R=8 when +/// SHARPY_ISOLATION_SWEEP_FULL=1 is set in the environment (read only — a test never sets it). +/// A cell is red in a repetition when any of its observations in that repetition is; k/R is +/// the number of red repetitions. +/// +/// Ratchet. Rows program sharing threads operation bucket [probabilistic] # #2250 +/// reason; reproduced k/R @ sha in Conformance/in-process-isolation-allowlist.txt. A red +/// cell without a row fails; a row whose cell was exercised and reproduced in no repetition fails as +/// stale (drain on fix) unless it is marked probabilistic — those are reported, not judged. A +/// row whose cell the mode does not exercise (another thread count) is neither. Reports: +/// .claude/tmp/in-process-isolation-report.json (GapDiscovery) and +/// .claude/tmp/in-process-isolation-slice-report.json (regular slice). +/// +public class InProcessIsolationSweepTests +{ + private const string AllowlistFileName = "in-process-isolation-allowlist.txt"; + private const string FullModeVariable = "SHARPY_ISOLATION_SWEEP_FULL"; + private const string IssueCite = "#2250"; + private const string Probabilistic = "probabilistic"; + + internal const string Drift = "drift"; + internal const string Crash = "crash"; + internal const string Order = "order"; + + /// A healthy compile takes well under a second; past this the instance is stuck, not slow. + private static readonly TimeSpan OperationTimeout = TimeSpan.FromMinutes(2); + + /// Worker stack: the compiler recurses over deep ASTs; a stack overflow would take the testhost down. + private const int WorkerStackBytes = 16 * 1024 * 1024; + + private readonly ITestOutputHelper _output; + + public InProcessIsolationSweepTests(ITestOutputHelper output) + { + _output = output; + } + + internal enum Sharing + { + CompilerPerThread, + ApiPerThread, + ApiShared, + } + + private static readonly Sharing[] AllSharing = { Sharing.CompilerPerThread, Sharing.ApiPerThread, Sharing.ApiShared }; + + internal static string Name(Sharing sharing) => sharing switch + { + Sharing.CompilerPerThread => "compiler-per-thread", + Sharing.ApiPerThread => "api-per-thread", + Sharing.ApiShared => "api-shared", + _ => throw new ArgumentOutOfRangeException(nameof(sharing)), + }; + + internal static string Name(IsolationOperation operation) => operation switch + { + IsolationOperation.Analyze => "analyze", + IsolationOperation.Compile => "compile", + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + + private enum InstanceKind + { + Compiler, + Api, + } + + private static InstanceKind KindOf(Sharing sharing) => sharing == Sharing.CompilerPerThread ? InstanceKind.Compiler : InstanceKind.Api; + + // ================================================================ + // Modes + // ================================================================ + + [Fact] + [Trait("Category", "GapDiscovery")] + public void Sweep_EightThreads_EightRepetitions() + => RunSweep("gap-discovery", new[] { 8 }, repetitions: 8, "in-process-isolation-report.json"); + + [Fact] + public void Sweep_RegularSlice() + { + if (Environment.GetEnvironmentVariable(FullModeVariable) == "1") + RunSweep("full", new[] { 1, 2, 8 }, repetitions: 8, "in-process-isolation-slice-report.json"); + else + RunSweep("regular", new[] { 1, 2 }, repetitions: 1, "in-process-isolation-slice-report.json"); + } + + // ================================================================ + // Controls + // ================================================================ + + /// + /// The oracle discriminates: two DIFFERENT programs observed through the sweep's own + /// have different fingerprints, and buckets the + /// difference by thread count (order on 1, drift on 2 and 8) and an escaped exception as crash; + /// the same program on two fresh instances is equal (the no-difference control), and a diagnostic + /// moved by one line is a different fingerprint (positions are compared, not just codes). + /// + [Fact] + public void Oracle_DiscriminatesPrograms_PositionsAndBuckets() + { + var clean = new IsolationProgram("control/clean", "control.spy", "def main() -> None:\n print(1)\n"); + var refused = new IsolationProgram("control/refused", "control.spy", "def main() -> None:\n print(undefined_name)\n"); + var moved = new IsolationProgram("control/moved", "control.spy", "\ndef main() -> None:\n print(undefined_name)\n"); + + foreach (var kind in new[] { InstanceKind.Compiler, InstanceKind.Api }) + { + foreach (var operation in new[] { IsolationOperation.Analyze, IsolationOperation.Compile }) + { + var a = Solo(kind, new IsolationStep(clean, operation)); + var a2 = Solo(kind, new IsolationStep(clean, operation)); + var b = Solo(kind, new IsolationStep(refused, operation)); + var c = Solo(kind, new IsolationStep(moved, operation)); + var cell = $"{kind}/{Name(operation)}"; + + Assert.False(a.Cancelled || b.Cancelled || c.Cancelled, cell); + Assert.True(a.Exception == null && b.Exception == null && c.Exception == null, $"{cell}: {a.Exception}{b.Exception}{c.Exception}"); + Assert.True(a.Success, $"{cell}: the clean control must compile: {a}"); + Assert.False(b.Success, $"{cell}: the refused control must be refused: {b}"); + Assert.Equal(a, a2); + Assert.Null(Classify(a, a2, threads: 1)); + Assert.NotEqual(a, b); + Assert.NotEqual(b, c); + Assert.Equal(b.Diagnostics.Split(',').Select(d => d.Split('@')[0]), c.Diagnostics.Split(',').Select(d => d.Split('@')[0])); + Assert.Equal(Order, Classify(a, b, threads: 1)); + Assert.Equal(Drift, Classify(a, b, threads: 2)); + Assert.Equal(Drift, Classify(b, c, threads: 8)); + Assert.Equal(Crash, Classify(a, a with { Exception = nameof(InvalidOperationException) }, threads: 8)); + } + } + } + + /// The schedules are pure functions of the literal seeds, and every thread's schedule + /// carries every named program under both operations. + [Fact] + public void Schedules_AreSeededAndCarryTheRoster() + { + Assert.Equal(8, InProcessIsolationCorpus.ThreadSeeds.Distinct().Count()); + Assert.StartsWith(InProcessIsolationCorpus.Cell1FirstLine, InProcessIsolationCorpus.RegenerateCell1Program().Source, StringComparison.Ordinal); + Assert.True(InProcessIsolationCorpus.Sample.Count > 100, $"stride sample has {InProcessIsolationCorpus.Sample.Count} programs"); + for (var t = 0; t < InProcessIsolationCorpus.ThreadSeeds.Length; t++) + { + var first = InProcessIsolationCorpus.Schedule(t).Select(s => $"{s.Program.Id}/{s.Operation}").ToList(); + var second = InProcessIsolationCorpus.Schedule(t).Select(s => $"{s.Program.Id}/{s.Operation}").ToList(); + Assert.Equal(first, second); + foreach (var program in InProcessIsolationCorpus.Named) + { + Assert.Contains($"{program.Id}/{IsolationOperation.Analyze}", first); + Assert.Contains($"{program.Id}/{IsolationOperation.Compile}", first); + } + } + + Assert.NotEqual( + InProcessIsolationCorpus.Schedule(0).Select(s => s.Program.Id), + InProcessIsolationCorpus.Schedule(1).Select(s => s.Program.Id)); + } + + // ================================================================ + // The sweep + // ================================================================ + + private sealed record Observation(int Repetition, Sharing Sharing, int Threads, int Thread, int Step, IsolationStep Work, Fingerprint Fingerprint); + + private sealed record RedCell(string Key, string Bucket, int Reds, int Repetitions, string Oracle, string Observed); + + private void RunSweep(string mode, int[] threadCounts, int repetitions, string reportFileName) + { + var wall = Stopwatch.StartNew(); + var maxThreads = threadCounts.Max(); + var schedules = Enumerable.Range(0, maxThreads).Select(InProcessIsolationCorpus.Schedule).ToList(); + + // 1. The solo oracle, before any concurrent phase: a fresh instance per (kind, program, operation). + var oracle = new Dictionary<(InstanceKind, string, IsolationOperation), Fingerprint>(); + foreach (var kind in new[] { InstanceKind.Compiler, InstanceKind.Api }) + { + foreach (var step in schedules.SelectMany(s => s)) + { + var key = (kind, step.Program.Id, step.Operation); + if (!oracle.ContainsKey(key)) + oracle[key] = Solo(kind, step); + } + } + + var oracleSeconds = wall.Elapsed.TotalSeconds; + + // 2. The concurrent phase: repetitions × sharing × threads, each a barrier-started set of workers. + var observations = new List(); + for (var repetition = 0; repetition < repetitions; repetition++) + { + foreach (var sharing in AllSharing) + { + foreach (var threads in threadCounts) + observations.AddRange(RunConfiguration(repetition, sharing, threads, schedules)); + } + } + + // 3. Classification: a cell is (program, sharing, threads, operation); red in a repetition when any observation is. + var exercised = new HashSet(StringComparer.Ordinal); + var redByKey = new Dictionary>(StringComparer.Ordinal); + var example = new Dictionary(StringComparer.Ordinal); + var cancelled = 0; + var observed = 0; + foreach (var o in observations) + { + var solo = oracle[(KindOf(o.Sharing), o.Work.Program.Id, o.Work.Operation)]; + if (o.Fingerprint.Cancelled || solo.Cancelled) + { + cancelled++; + continue; + } + + observed++; + var cell = $"{o.Work.Program.Id} {Name(o.Sharing)} {o.Threads} {Name(o.Work.Operation)}"; + exercised.Add(cell); + var bucket = Classify(solo, o.Fingerprint, o.Threads); + if (bucket == null) + continue; + var key = $"{cell} {bucket}"; + if (!redByKey.TryGetValue(key, out var reps)) + redByKey[key] = reps = new HashSet(); + reps.Add(o.Repetition); + example.TryAdd(key, (solo.ToString(), o.Fingerprint.ToString())); + } + + var reds = redByKey + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .Select(kv => new RedCell(kv.Key, kv.Key.Split(' ')[4], kv.Value.Count, repetitions, example[kv.Key].Oracle, example[kv.Key].Observed)) + .ToList(); + + // 4. The ratchet. + var allowlist = LoadAllowlist(); + var unlisted = reds.Where(r => !allowlist.ContainsKey(r.Key)).ToList(); + var notExercised = allowlist.Keys.Where(k => !exercised.Contains(CellOf(k))).OrderBy(k => k, StringComparer.Ordinal).ToList(); + var silent = allowlist + .Where(kv => exercised.Contains(CellOf(kv.Key)) && !redByKey.ContainsKey(kv.Key)) + .OrderBy(kv => kv.Key, StringComparer.Ordinal) + .ToList(); + var stale = silent.Where(kv => !kv.Value.Probabilistic).Select(kv => kv.Key).ToList(); + var silentProbabilistic = silent.Where(kv => kv.Value.Probabilistic).Select(kv => kv.Key).ToList(); + + var bucketCounts = new[] { Drift, Crash, Order }.ToDictionary(b => b, b => reds.Count(r => r.Bucket == b)); + var census = $"ISOLATION-CENSUS mode={mode} threads={{{string.Join(",", threadCounts)}}} R={repetitions} " + + $"corpus={InProcessIsolationCorpus.Named.Count}+{InProcessIsolationCorpus.Sample.Count} " + + $"oracle={oracle.Count} observations={observations.Count} compared={observed} cancelled={cancelled} " + + $"cells={exercised.Count} red={reds.Count} drift={bucketCounts[Drift]} crash={bucketCounts[Crash]} order={bucketCounts[Order]} " + + $"allowlisted={allowlist.Count} oracle-elapsed={oracleSeconds:F0}s elapsed={wall.Elapsed.TotalSeconds:F0}s"; + _output.WriteLine(census); + foreach (var red in reds) + _output.WriteLine($"ISOLATION {red.Key} reproduced {red.Reds}/{red.Repetitions}\n solo: {red.Oracle}\n observed: {red.Observed}"); + foreach (var key in silentProbabilistic) + _output.WriteLine($"ISOLATION probabilistic row not reproduced this run: {key}"); + + WriteReport(reportFileName, new + { + census, + mode, + threads = threadCounts, + repetitions, + buckets = bucketCounts, + reds = reds.Select(r => new { cell = r.Key, bucket = r.Bucket, reproduced = $"{r.Reds}/{r.Repetitions}", solo = r.Oracle, observed = r.Observed }), + unlisted = unlisted.Select(r => r.Key), + stale, + silentProbabilistic, + notExercised, + oracle = oracle + .OrderBy(kv => kv.Key.Item2, StringComparer.Ordinal).ThenBy(kv => kv.Key.Item1).ThenBy(kv => kv.Key.Item3) + .Select(kv => new { kind = kv.Key.Item1.ToString(), program = kv.Key.Item2, operation = Name(kv.Key.Item3), fingerprint = kv.Value.ToString() }), + }); + + var problems = new List(); + problems.AddRange(unlisted.Select(r => + $"{r.Key}: reproduced {r.Reds}/{r.Repetitions} — the {r.Bucket} is not allowlisted.\n solo: {r.Oracle}\n observed: {r.Observed}")); + problems.AddRange(stale.Select(k => + $"{k}: exercised and reproduced 0/{repetitions} — drain the row from Conformance/{AllowlistFileName} (or mark it `{Probabilistic}` with its measured k/R).")); + Assert.True(problems.Count == 0, + $"{problems.Count} in-process isolation violation(s) ({mode}):\n " + string.Join("\n ", problems) + + $"\nReport: .claude/tmp/{reportFileName}"); + } + + private static string CellOf(string key) => key.Substring(0, key.LastIndexOf(' ')); + + private static List RunConfiguration(int repetition, Sharing sharing, int threads, IReadOnlyList> schedules) + { + var observations = new ConcurrentBag(); + var failures = new ConcurrentBag(); + using var shared = sharing == Sharing.ApiShared ? IsolationInstance.Create(InstanceKind.Api) : null; + using var barrier = new Barrier(threads); + var workers = Enumerable.Range(0, threads).Select(t => new Thread(() => + { + try + { + using var own = shared == null ? IsolationInstance.Create(KindOf(sharing)) : null; + var instance = shared ?? own!; + barrier.SignalAndWait(); + var schedule = schedules[t]; + for (var step = 0; step < schedule.Count; step++) + observations.Add(new Observation(repetition, sharing, threads, t, step, schedule[step], instance.Observe(schedule[step]))); + } + catch (Exception ex) + { + failures.Add(ex); + } + }, WorkerStackBytes) + { IsBackground = true, Name = $"isolation-{Name(sharing)}-{threads}-{t}" }).ToList(); + + foreach (var worker in workers) + worker.Start(); + foreach (var worker in workers) + worker.Join(); + + Assert.True(failures.IsEmpty, "the sweep's own worker failed (an instrument fault, not a cell): " + string.Join("\n", failures)); + return observations.ToList(); + } + + /// The bucket of an observation against its solo oracle, or null when they agree. + internal static string? Classify(Fingerprint solo, Fingerprint observed, int threads) + { + if (observed.Exception != null && solo.Exception == null) + return Crash; + if (observed == solo) + return null; + return threads == 1 ? Order : Drift; + } + + private static Fingerprint Solo(InstanceKind kind, IsolationStep step) + { + using var instance = IsolationInstance.Create(kind); + return instance.Observe(step); + } + + // ================================================================ + // Instances and fingerprints + // ================================================================ + + /// + /// What one operation answered. Equality is the comparison: success, the sorted + /// (code, line, col) multiset, the used-assembly set (null where not exposed) and the type + /// of an escaped exception. marks an observation that is never compared. + /// + internal sealed record Fingerprint(bool Success, string Diagnostics, string? UsedAssemblies, string? Exception, bool Cancelled) + { + /// The first error's message, or the escaped exception's — for the report only, never compared. + public string Detail { get; init; } = ""; + + public bool Equals(Fingerprint? other) + => other is not null && Success == other.Success && Diagnostics == other.Diagnostics + && UsedAssemblies == other.UsedAssemblies && Exception == other.Exception && Cancelled == other.Cancelled; + + public override int GetHashCode() => HashCode.Combine(Success, Diagnostics, UsedAssemblies, Exception, Cancelled); + + public override string ToString() + => (Exception != null + ? $"threw {Exception}" + : $"success={Success} diagnostics=[{Diagnostics}] used=[{UsedAssemblies ?? "n/a"}]{(Cancelled ? " CANCELLED" : "")}") + + (Detail.Length == 0 ? "" : $" — {Detail}"); + + internal static Fingerprint Of(bool success, IEnumerable diagnostics, IEnumerable? usedAssemblies) + { + var list = diagnostics.ToList(); + var cancelled = list.Any(d => d.Code == DiagnosticCodes.Infrastructure.CompilationCancelled); + var multiset = string.Join(",", list + .Select(d => $"{d.Code ?? "-"}@{d.Line ?? 0}:{d.Column ?? 0}") + .OrderBy(s => s, StringComparer.Ordinal)); + var used = usedAssemblies == null + ? null + : string.Join(";", usedAssemblies.OrderBy(p => p, StringComparer.Ordinal)); + var firstError = list.FirstOrDefault(d => d.IsError)?.Message ?? ""; + return new Fingerprint(success, multiset, used, null, cancelled) { Detail = FirstLine(firstError) }; + } + + internal static Fingerprint Threw(Exception ex) + => new(false, "", null, ex.GetType().FullName, false) { Detail = FirstLine(ex.Message) }; + + private static string FirstLine(string text) + { + var line = text.Split('\n')[0]; + return line.Length <= 240 ? line : line.Substring(0, 240) + "…"; + } + } + + /// One compiler instance of a sharing mode, with the scratch directory its compiles write into. + private abstract class IsolationInstance : IDisposable + { + /// + /// Named as the CLI names it (): with an output path + /// and no assembly name a single-file compile emits an assembly named "" — SPY0908 + /// CS8203 (measured; a findings-ledger row, outside #2250). + /// + protected const string AssemblyName = "IsolationProgram"; + + protected readonly string Root = Path.Combine(Path.GetTempPath(), $"sharpy_isolation_{Guid.NewGuid():N}"); + + internal static IsolationInstance Create(InstanceKind kind) + => kind == InstanceKind.Compiler ? new CompilerInstance() : new ApiInstance(); + + internal Fingerprint Observe(IsolationStep step) + { + try + { + using var cts = new CancellationTokenSource(OperationTimeout); + return step.Operation == IsolationOperation.Analyze + ? Analyze(step.Program, cts.Token) + : Compile(step.Program, cts.Token); + } + catch (Exception ex) + { + return Fingerprint.Threw(ex); + } + } + + protected abstract Fingerprint Analyze(IsolationProgram program, CancellationToken cancellationToken); + + protected abstract Fingerprint Compile(IsolationProgram program, CancellationToken cancellationToken); + + public void Dispose() + { + try + { + if (Directory.Exists(Root)) + Directory.Delete(Root, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + } + + /// + /// One for the instance's life — its module registry is built once, in the + /// constructor, and serves every program of the schedule. Sharpy.Core is referenced (without it + /// the C# → IL emit of print(1) is SPY0908 — measured), and the output path makes + /// Compile reach IL. + /// + private sealed class CompilerInstance : IsolationInstance + { + private readonly Compiler _compiler; + + public CompilerInstance() + { + Directory.CreateDirectory(Root); + _compiler = new Compiler(CompilerOptionsFactory.ForCli( + references: new[] { SharpyCoreReference.Location }, + assemblyName: AssemblyName, + outputAssemblyPath: Path.Combine(Root, AssemblyName + ".dll")), NullLogger.Instance); + } + + protected override Fingerprint Analyze(IsolationProgram program, CancellationToken cancellationToken) + { + var result = _compiler.Analyze(program.Source, program.FileName, cancellationToken); + return Fingerprint.Of(result.Success, result.Diagnostics.GetAll(), result.ModuleRegistry?.GetUsedAssemblyPaths()); + } + + protected override Fingerprint Compile(IsolationProgram program, CancellationToken cancellationToken) + { + var result = _compiler.Compile(program.Source, program.FileName, cancellationToken); + return Fingerprint.Of(result.Success, result.Diagnostics.GetAll(), result.ModuleRegistry?.GetUsedAssemblyPaths()); + } + } + + /// One referencing Sharpy.Core; each Compile writes into its own directory. + private sealed class ApiInstance : IsolationInstance + { + private readonly CompilerApi _api = new(NullLogger.Instance, new[] { SharpyCoreReference.Location }); + + protected override Fingerprint Analyze(IsolationProgram program, CancellationToken cancellationToken) + { + var result = _api.AnalyzeDocument(program.Source, program.FileName, CompilerOptionsFactory.ForCli(), + stageMetrics: null, cancellationToken); + return Fingerprint.Of(result.Success, result.Diagnostics, usedAssemblies: null); + } + + protected override Fingerprint Compile(IsolationProgram program, CancellationToken cancellationToken) + { + var callDir = Path.Combine(Root, Guid.NewGuid().ToString("N")); + try + { + var options = CompilerOptionsFactory.ForCli( + assemblyName: AssemblyName, + outputAssemblyPath: Path.Combine(callDir, AssemblyName + ".dll")); + var result = _api.Compile(program.Source, options, program.FileName, cancellationToken); + return Fingerprint.Of(result.Success, result.Diagnostics, result.UsedAssemblyPaths); + } + finally + { + try + { + if (Directory.Exists(callDir)) + Directory.Delete(callDir, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + } + } + + // ================================================================ + // Allowlist and report + // ================================================================ + + private sealed record AllowlistRow(bool Probabilistic, string Comment); + + /// + /// program sharing threads operation bucket [probabilistic] # #2250 reason; reproduced k/R @ sha. + /// A row that does not parse, names an unknown sharing/operation/bucket, or does not cite #2250 is + /// refused (an instrument fault), never silently skipped. + /// + private static Dictionary LoadAllowlist() + { + var path = Path.Combine(FixtureRoots.RepositoryRoot, "src", "Sharpy.Compiler.Tests", "Conformance", AllowlistFileName); + if (!File.Exists(path)) + throw new InvalidOperationException($"Conformance/{AllowlistFileName} is missing. Its presence is what arms the ratchet."); + + var sharingNames = AllSharing.Select(Name).ToHashSet(StringComparer.Ordinal); + var operationNames = new[] { IsolationOperation.Analyze, IsolationOperation.Compile }.Select(Name).ToHashSet(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + foreach (var raw in File.ReadAllLines(path)) + { + var line = raw.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + continue; + var split = line.IndexOf(" #", StringComparison.Ordinal); + var comment = split < 0 ? "" : line.Substring(split + 2).Trim(); + var fields = (split < 0 ? line : line.Substring(0, split)).Split(' ', StringSplitOptions.RemoveEmptyEntries); + var probabilistic = fields.Length == 6 && fields[5] == Probabilistic; + if (fields.Length != 5 && !probabilistic) + throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — expected `program sharing threads operation bucket [{Probabilistic}] # {IssueCite} …`."); + if (!sharingNames.Contains(fields[1]) || fields[2] is not ("1" or "2" or "8") || !operationNames.Contains(fields[3]) + || fields[4] is not (Drift or Crash or Order)) + throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — unknown sharing, thread count, operation or bucket."); + if (!comment.Contains(IssueCite, StringComparison.Ordinal)) + throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{line}' — every row cites {IssueCite}."); + var key = string.Join(' ', fields.Take(5)); + if (!rows.TryAdd(key, new AllowlistRow(probabilistic, comment))) + throw new InvalidOperationException($"Conformance/{AllowlistFileName}: '{key}' is listed twice."); + } + + return rows; + } + + private void WriteReport(string fileName, object report) + { + var reportDir = Path.Combine(FixtureRoots.RepositoryRoot, ".claude", "tmp"); + Directory.CreateDirectory(reportDir); + var reportPath = Path.Combine(reportDir, fileName); + File.WriteAllText(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true })); + _output.WriteLine($"Report written to: {reportPath}"); + } +} diff --git a/src/Sharpy.Compiler.Tests/Conformance/ProcessGlobalStateConformanceTests.cs b/src/Sharpy.Compiler.Tests/Conformance/ProcessGlobalStateConformanceTests.cs index e65f81fd3..c6e6fd8fb 100644 --- a/src/Sharpy.Compiler.Tests/Conformance/ProcessGlobalStateConformanceTests.cs +++ b/src/Sharpy.Compiler.Tests/Conformance/ProcessGlobalStateConformanceTests.cs @@ -538,6 +538,9 @@ private static readonly (string Project, string Collection, string Reason, strin PropertiesNs + "Semantic.ValidatorOrderingPropertyTests", PropertiesNs + "Semantic.VariancePropertyTests", PropertiesNs + "Stress.LargeProgramPropertyTests", }), + ("Sharpy.Compiler.Tests", "LoadContextRetention", + "forces full GCs to measure the per-reference-set load contexts a long-lived host retains (#2250); a forced collection stalls every concurrently running test (#2254)", + new[] { "Sharpy.Compiler.Tests.Project.RegistryIsolationTests" }), ("Sharpy.Stdlib.Tests", "ProcessCwd", "os.chdir changes the current directory and os.putenv the environment; getcwd, Path.cwd and relative paths read them", new[] { "Sharpy.Stdlib.Tests.Spy.Os.OsModuleTests.OsModuleTestsModuleTests" }), @@ -558,10 +561,10 @@ private static readonly (string Project, string Collection, string Reason, strin }; /// Positive control: roster size counted by hand from the P2/P4/P5 audit (not from the array). - private const int ExpectedSerialCollections = 7; + private const int ExpectedSerialCollections = 8; - /// 1 + 1 + 44 (Compiler) + 1 + 3 + 1 (Stdlib) + 1 (Core), counted by hand; the WallClockWindow row (#2179 P5.1) drained with #2251. - private const int ExpectedSerialMembers = 52; + /// 1 + 1 + 44 + 1 (Compiler; LoadContextRetention #2250) + 1 + 3 + 1 (Stdlib) + 1 (Core), counted by hand; the WallClockWindow row (#2179 P5.1) drained with #2251. + private const int ExpectedSerialMembers = 53; [Fact] public void EveryRunAloneCollection_IsRostered_AndEveryRosterRowIsDefined() diff --git a/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs b/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs new file mode 100644 index 000000000..8a88373d4 --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Conformance/ProcessWideResolutionScanTests.cs @@ -0,0 +1,451 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Sharpy.Compiler.Tests.Infrastructure; +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Compiler.Tests.Conformance; + +/// +/// Every place the compiler resolves a CLR name against — or loads an assembly into — the PROCESS +/// rather than its own compilation is a listed row (#2250). +/// +/// Why. AppDomain.CurrentDomain.GetAssemblies() answers "whatever this process has +/// loaded so far", the static Type.GetType(string) answers against the same set, and +/// Assembly.LoadFrom loads into the one default AssemblyLoadContext, where the first copy +/// of a simple name wins for every later compile. A compile that consults any of them is a function of +/// the process's history, not of its inputs — the in-process isolation class P54 cuts at ONE +/// visibility seam owned by the compilation's registry. +/// +/// What it scans. A Roslyn SEMANTIC walk over src/Sharpy.Compiler/**/*.cs (bin/obj +/// excluded), compiled with the compiler's own references (the trusted-platform list, Sharpy.Core under +/// its SharpyRT alias) and its SDK global usings — without them the receivers bind to error types +/// and a symbol match silently finds nothing. A site is a name whose BOUND symbol is one of: +/// System.AppDomain.GetAssemblies; the STATIC System.Type.GetType (any overload — the +/// instance object.GetType() is not a resolution site, which is why this is a symbol match and not +/// a token grep); a static System.Reflection.Assembly.Load* (Load, LoadFrom, +/// LoadFile, UnsafeLoadFrom, LoadWithPartialName), invoked or passed as a method +/// group; AssemblyLoadContext.Default; or the construction of an AssemblyLoadContext +/// (or a type derived from it). Every candidate the scan inspects must bind: an unresolved candidate is +/// a red, never a skip, so a degraded compilation cannot pass by finding nothing. +/// +/// Two lists, as StaticStateConformanceTests has. is +/// permanent: each row is key-pure or self-owned and says why. is drain-on-fix: +/// each row is a process-wide resolution site citing #2250, deleted when P54 Phase 2 routes it through +/// the registry's visibility (a site that seam itself keeps becomes a Roster row). Rows are keyed by +/// file + enclosing member + site kind with a site COUNT — never a line number, which drifts. An +/// unlisted key, a count mismatch, and a stale row (no site) are each red, and the totals are anchored +/// to the literal census measured at the guard's birth (re-anchored as rows drain). +/// +public class ProcessWideResolutionScanTests +{ + private const string CompilerProjectDir = "src/Sharpy.Compiler"; + private const string CompilerCsproj = "src/Sharpy.Compiler/Sharpy.Compiler.csproj"; + private const string SynthesizedGlobalUsingsPath = "GlobalUsings.synthesized.cs"; + + // Site kinds — the rows' third key component. + private const string AppDomainScan = "AppDomain.GetAssemblies"; + private const string StaticTypeGetType = "Type.GetType"; + private const string NewLoadContext = "new AssemblyLoadContext"; + private const string DefaultLoadContext = "AssemblyLoadContext.Default"; + + /// + /// Names whose binding the scan inspects — the symbol match needs a candidate set, and every + /// spelling of a listed member reaches the binder through one of these simple names. + /// + private static readonly ImmutableHashSet CandidateNames = ImmutableHashSet.Create( + StringComparer.Ordinal, + "GetAssemblies", "GetType", "Load", "LoadFrom", "LoadFile", "UnsafeLoadFrom", + "LoadWithPartialName", "Default"); + + /// + /// Permanent rows: file::Type.Member::kind → (site count, why the site is not a process-wide + /// resolution). Key-pure or self-owned only. + /// + private static readonly IReadOnlyDictionary Roster = + new Dictionary(StringComparer.Ordinal) + { + ["AssemblyCompiler.cs::AssemblyCompiler.GetMetadataReferences::Assembly.Load"] = (1, + "Assembly.Load(\"netstandard\") — a framework simple name binds to the trusted-platform copy " + + "in the default context, a process constant; only its Location is read, to add a reference."), + ["Services/ReplSession.cs::ReplSession.BuildMetadataReferences::Assembly.Load"] = (3, + "Assembly.Load(\"System.Runtime\" / \"System.Collections\" / \"netstandard\") — framework " + + "simple names bind to the trusted-platform copies, a process constant; only Location is read."), + ["Services/ReplSession.cs::ReplSession.ExecuteAssembly::Assembly.Load"] = (1, + "Assembly.Load(bytes) of the REPL's OWN emitted submission — nothing resolves it by name; " + + "the session owns it. That a later process-wide scan can see it is the Allowlist sites' " + + "defect, cured at the visibility seam; REPL isolation itself is #2310."), + ["Project/ProjectCompiler.Generators.cs::ProjectCompiler.CompileGeneratorAssembly::new AssemblyLoadContext"] = (1, + "the collectible SharplyGenerators context — self-owned, loads only the generator assembly " + + "this project just compiled (LoadFromStream); the precedent for the per-registry context " + + "of P54 Design Decision 2."), + + // ---- The ONE visibility seam (#2250, P54 Design Decision 1): every CLR-name question the + // compiler asks is answered here, from {framework} ∪ {the registry's own references}. ---- + ["Discovery/AssemblyVisibility.cs::AssemblyVisibility.TypeNameResolution::Type.GetType"] = (1, + "the one seam: the runtime's type-name parser called with BOTH resolvers supplied — the " + + "assembly qualifier binds against the visible set by simple name and an unqualified name is " + + "searched in the visible set, so nothing binds against the process."), + ["Discovery/AssemblyVisibility.cs::AssemblyVisibility.LoadFrameworkAssembly::Assembly.Load"] = (1, + "the one seam: Assembly.Load(AssemblyName) of a trusted-platform System.*/Microsoft.*/netstandard/" + + "mscorlib name (or a compiler dependency), once per process behind the framework Lazy — the " + + "default context's TPA binding, a process constant."), + ["Discovery/AssemblyVisibility.cs::AssemblyVisibility.IsProcessStable::AssemblyLoadContext.Default"] = (1, + "the one seam: Design Decision 3's gate — a static Type-keyed cache holds a type only when it is " + + "wholly in the default context, so no static cache pins a per-registry load context."), + ["Discovery/ReferenceLoadContext.cs::ReferenceLoadContext.GetOrLoad::new AssemblyLoadContext"] = (1, + "the one seam's other half (P54 Design Decision 2): a collectible context keyed by the registry's " + + "reference set (file identities + module paths), weakly shared — its contents are a function of " + + "the key; Load() defers to the default context (one framework / Sharpy.Core identity) unless " + + "the name is one of the set's own."), + }; + + /// + /// Drain-on-fix rows: every process-wide type-resolution site @ 486c9a88a. Each drains in P54 + /// Phase 2 (#2250) when it answers from the compilation's registry visibility instead. + /// + private static readonly IReadOnlyDictionary Allowlist = + new Dictionary(StringComparer.Ordinal) + { + }; + + private readonly ITestOutputHelper _output; + + public ProcessWideResolutionScanTests(ITestOutputHelper output) => _output = output; + + internal sealed record Site(string File, string Enclosing, string Kind, int Line) + { + public string Key => $"{File}::{Enclosing}::{Kind}"; + } + + internal sealed record UnresolvedCandidate(string File, int Line, string Text, string Why); + + internal sealed record ScanResult(IReadOnlyList Sites, IReadOnlyList Unresolved); + + private static readonly Lazy> CompilerReferences = new(() => + { + var csproj = Path.GetFullPath(Path.Combine(DispatchSiteScan.FindRepoRoot(), CompilerCsproj)); + return DispatchSiteScan.BuildReferences( + DispatchSiteScan.ReadAliasMap(csproj), DispatchSiteScan.GetAssemblyName(csproj)); + }); + + private static readonly Lazy CompilerScan = new(ScanCompilerSources); + + private static ScanResult ScanCompilerSources() + { + var repoRoot = DispatchSiteScan.FindRepoRoot(); + var projectDir = Path.GetFullPath(Path.Combine(repoRoot, CompilerProjectDir)); + var csproj = Path.GetFullPath(Path.Combine(repoRoot, CompilerCsproj)); + var sep = Path.DirectorySeparatorChar; + + var trees = Directory.GetFiles(projectDir, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{sep}bin{sep}") && !f.Contains($"{sep}obj{sep}")) + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => Parse(File.ReadAllText(f), f)) + .ToList(); + trees.Add(Parse(DispatchSiteScan.BuildGlobalUsingsSource(csproj), SynthesizedGlobalUsingsPath)); + + return Scan(trees, CompilerReferences.Value, projectDir); + } + + private static SyntaxTree Parse(string source, string path) => + CSharpSyntaxTree.ParseText( + source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest), path: path); + + /// + /// The ONE classifier — the compiler census and every synthetic control run through it. + /// + internal static ScanResult Scan(IReadOnlyList trees, IEnumerable references, string root) + { + var compilation = CSharpCompilation.Create( + "ProcessWideResolutionScan", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(NullableContextOptions.Enable)); + + var loadContextType = compilation.GetTypeByMetadataName("System.Runtime.Loader.AssemblyLoadContext"); + var sites = new List(); + var unresolved = new List(); + + // Without the load-context type the construction and Default arms are blind: a degraded + // reference set, reported rather than silently scanned around. + if (loadContextType == null) + { + unresolved.Add(new UnresolvedCandidate( + "", 0, "System.Runtime.Loader.AssemblyLoadContext", "type not found")); + } + + // Source types deriving (by spelling) from AssemblyLoadContext, so an explicit + // `new DerivedContext(...)` is a construction candidate too; the semantic check decides. + var contextTypeNames = new HashSet(StringComparer.Ordinal) { "AssemblyLoadContext" }; + foreach (var tree in trees) + { + foreach (var decl in tree.GetRoot().DescendantNodes().OfType()) + { + if (decl.BaseList?.Types.Any(t => RightmostName(t.Type) == "AssemblyLoadContext") == true) + contextTypeNames.Add(decl.Identifier.Text); + } + } + + foreach (var tree in trees) + { + if (tree.FilePath == SynthesizedGlobalUsingsPath) + continue; + + var model = compilation.GetSemanticModel(tree); + var file = Path.GetRelativePath(root, tree.FilePath).Replace('\\', '/'); + + foreach (var node in tree.GetRoot().DescendantNodes()) + { + switch (node) + { + case SimpleNameSyntax name when CandidateNames.Contains(name.Identifier.Text): + ClassifyName(name, model, file, sites, unresolved); + break; + case ObjectCreationExpressionSyntax creation + when contextTypeNames.Contains(RightmostName(creation.Type)): + ClassifyCreation(creation, model, loadContextType, file, sites, unresolved); + break; + case ImplicitObjectCreationExpressionSyntax implicitCreation: + ClassifyCreation(implicitCreation, model, loadContextType, file, sites, unresolved); + break; + } + } + } + + return new ScanResult(sites, unresolved); + } + + private static void ClassifyName( + SimpleNameSyntax name, SemanticModel model, string file, List sites, List unresolved) + { + // A named argument (`Default: x`) names a parameter, not a member. + if (name.Parent is NameColonSyntax) + return; + + var info = model.GetSymbolInfo(name); + var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + if (symbol == null) + { + unresolved.Add(new UnresolvedCandidate( + file, LineOf(name), name.Parent?.ToString() ?? name.ToString(), info.CandidateReason.ToString())); + return; + } + + var kind = KindOf(symbol); + if (kind != null) + sites.Add(new Site(file, DispatchSiteScan.EnclosingContext(name), kind, LineOf(name))); + } + + private static void ClassifyCreation( + BaseObjectCreationExpressionSyntax creation, SemanticModel model, INamedTypeSymbol? loadContextType, + string file, List sites, List unresolved) + { + var type = model.GetTypeInfo(creation).Type; + if (type == null || type.TypeKind == TypeKind.Error) + { + unresolved.Add(new UnresolvedCandidate(file, LineOf(creation), creation.ToString(), "creation type unbound")); + return; + } + + if (loadContextType != null && DerivesFrom(type, loadContextType)) + sites.Add(new Site(file, DispatchSiteScan.EnclosingContext(creation), NewLoadContext, LineOf(creation))); + } + + /// The site kind a bound symbol denotes, or null when it is not a listed member. + internal static string? KindOf(ISymbol symbol) + { + var containing = symbol.ContainingType?.ToDisplayString(); + return symbol switch + { + IMethodSymbol { Name: "GetAssemblies" } when containing == "System.AppDomain" => AppDomainScan, + IMethodSymbol { Name: "GetType", IsStatic: true } when containing == "System.Type" => StaticTypeGetType, + IMethodSymbol { IsStatic: true } m when containing == "System.Reflection.Assembly" + && (m.Name.StartsWith("Load", StringComparison.Ordinal) || m.Name == "UnsafeLoadFrom") + => "Assembly." + m.Name, + IPropertySymbol { Name: "Default", IsStatic: true } + when containing == "System.Runtime.Loader.AssemblyLoadContext" => DefaultLoadContext, + _ => null, + }; + } + + private static bool DerivesFrom(ITypeSymbol type, INamedTypeSymbol baseType) + { + for (var current = type; current != null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + } + return false; + } + + private static string RightmostName(TypeSyntax type) => type switch + { + QualifiedNameSyntax q => q.Right.Identifier.Text, + AliasQualifiedNameSyntax a => a.Name.Identifier.Text, + SimpleNameSyntax s => s.Identifier.Text, + _ => type.ToString(), + }; + + private static int LineOf(SyntaxNode node) => node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + + // ---- The census ---- + + [Fact] + public void CompilerSources_EveryProcessWideResolutionSite_IsRosteredOrAllowlisted() + { + var result = CompilerScan.Value; + + foreach (var u in result.Unresolved) + _output.WriteLine($"UNRESOLVED {u.File}:{u.Line} [{u.Why}] {u.Text}"); + foreach (var s in result.Sites.OrderBy(s => s.Key, StringComparer.Ordinal).ThenBy(s => s.Line)) + _output.WriteLine($"SITE {s.Key} @ line {s.Line}"); + + Assert.True(result.Unresolved.Count == 0, + "The scan's compilation is degraded: candidate names did not bind, so a site could hide behind " + + "an error type. Supply the missing reference/global using:\n " + + string.Join("\n ", result.Unresolved.Select(u => $"{u.File}:{u.Line} [{u.Why}] {u.Text}"))); + + var overlap = Roster.Keys.Intersect(Allowlist.Keys, StringComparer.Ordinal).ToList(); + Assert.True(overlap.Count == 0, "A key is in both Roster and Allowlist:\n " + string.Join("\n ", overlap)); + + var byKey = result.Sites.GroupBy(s => s.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); + + var violations = new List(); + foreach (var (key, found) in byKey.OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + var lines = string.Join(",", found.Select(s => s.Line)); + if (Roster.TryGetValue(key, out var row) || Allowlist.TryGetValue(key, out row)) + { + if (row.Count != found.Count) + violations.Add($"[count {found.Count} != row {row.Count}] {key} (lines {lines})"); + } + else + { + violations.Add($"[unlisted] {key} (lines {lines})"); + } + } + + var stale = Roster.Keys.Concat(Allowlist.Keys).Where(k => !byKey.ContainsKey(k)) + .OrderBy(k => k, StringComparer.Ordinal).ToList(); + + Assert.True(violations.Count == 0, + "Process-wide type-resolution / assembly-load sites in the compiler that no row accounts for " + + "(#2250). Route the lookup through the compilation's registry; a key-pure site is a Roster row " + + "with its justification:\n " + string.Join("\n ", violations)); + Assert.True(stale.Count == 0, + "Stale rows (no matching site — the site drained or moved; delete or re-key the row):\n " + + string.Join("\n ", stale)); + + // Literal census @ 486c9a88a (measured): 14 AppDomain scans + 7 static Type.GetType + 4 + // Assembly.LoadFrom = 25 resolution sites; 5 Assembly.Load + 1 load-context construction = 6 + // rostered sites. P54 Phase 2 task 1 (measured): the 14 scans and the 6 non-seam Type.GetType + // sites drained into AssemblyVisibility, whose own three seam sites are Roster rows. Task 2 + // (measured): the 3 remaining Allowlist rows drained — LoadReference loads into the registry's + // ReferenceLoadContext (its construction is a Roster row), AssemblyIdentity.FromAssemblyPath + // deleted, the transitional bind deleted. The Allowlist is EMPTY. + Assert.Equal(0, result.Sites.Count(s => s.Kind == AppDomainScan)); + Assert.Equal(1, result.Sites.Count(s => s.Kind == StaticTypeGetType)); + Assert.Empty(Allowlist); + Assert.Equal(10, result.Sites.Count(s => Roster.ContainsKey(s.Key))); + Assert.Equal(10, result.Sites.Count); + } + + // ---- Synthetic controls: the same classifier on in-memory sources ---- + + private static ScanResult ScanSynthetic(string source) + { + var root = Path.Combine(Path.GetTempPath(), "process-wide-scan-synthetic"); + return Scan(new[] { Parse(source, Path.Combine(root, "Probe.cs")) }, CompilerReferences.Value, root); + } + + [Fact] + public void Synthetic_AppDomainScan_IsFound() + { + var result = ScanSynthetic(""" + namespace Probe; + internal static class ProbeSites + { + internal static int Scan() => System.AppDomain.CurrentDomain.GetAssemblies().Length; + } + """); + + Assert.Empty(result.Unresolved); + var site = Assert.Single(result.Sites); + Assert.Equal("Probe.cs::ProbeSites.Scan::AppDomain.GetAssemblies", site.Key); + } + + /// + /// The instance object.GetType() is not a resolution site; the STATIC Type.GetType in + /// the same source is the positive control that the absence is the symbol match, not a blind scan. + /// + [Fact] + public void Synthetic_InstanceGetType_IsNotFound_StaticTypeGetType_Is() + { + var result = ScanSynthetic(""" + namespace Probe; + internal static class ProbeSites + { + internal static string Instance(object x) => x.GetType().Name; + internal static string OnType() => typeof(ProbeSites).GetType().Name; + internal static System.Type? Static() => System.Type.GetType("System.Int32"); + } + """); + + Assert.Empty(result.Unresolved); + var site = Assert.Single(result.Sites); + Assert.Equal("Probe.cs::ProbeSites.Static::Type.GetType", site.Key); + } + + /// One synthetic cell per remaining site kind and spelling — each arm is falsifiable. + [Theory] + [InlineData("internal static object M() => System.Reflection.Assembly.LoadFrom(\"x.dll\");", "Assembly.LoadFrom")] + [InlineData("internal static object M() => System.Reflection.Assembly.Load(\"netstandard\");", "Assembly.Load")] + [InlineData("internal static object M() => System.Reflection.Assembly.LoadFile(\"/x.dll\");", "Assembly.LoadFile")] + [InlineData("internal static object M() => System.Linq.Enumerable.Select(new string[0], System.Reflection.Assembly.LoadFrom);", "Assembly.LoadFrom")] + [InlineData("internal static object M() => System.Runtime.Loader.AssemblyLoadContext.Default;", "AssemblyLoadContext.Default")] + [InlineData("internal static object M() => new System.Runtime.Loader.AssemblyLoadContext(\"p\", isCollectible: true);", "new AssemblyLoadContext")] + [InlineData("internal static object M() { System.Runtime.Loader.AssemblyLoadContext c = new(\"p\"); return c; }", "new AssemblyLoadContext")] + [InlineData("internal static object M() => new Derived(); private sealed class Derived : System.Runtime.Loader.AssemblyLoadContext { }", "new AssemblyLoadContext")] + [InlineData("internal static object M() { var d = System.AppDomain.CurrentDomain; return d.GetAssemblies(); }", "AppDomain.GetAssemblies")] + public void Synthetic_EachSiteKind_IsFound(string member, string kind) + { + var result = ScanSynthetic($$""" + namespace Probe; + internal static class ProbeSites + { + {{member}} + } + """); + + Assert.Empty(result.Unresolved); + var site = Assert.Single(result.Sites); + Assert.Equal($"Probe.cs::ProbeSites.M::{kind}", site.Key); + } + + /// + /// A candidate that does not bind is reported, not skipped — the census's degraded-compilation + /// red. The bound AppDomain scan beside it is still found (positive control). + /// + [Fact] + public void Synthetic_UnboundCandidate_IsReportedUnresolved() + { + var result = ScanSynthetic(""" + namespace Probe; + internal static class ProbeSites + { + internal static object Unbound() => Missing.Domain.GetAssemblies(); + internal static int Bound() => System.AppDomain.CurrentDomain.GetAssemblies().Length; + } + """); + + var unresolved = Assert.Single(result.Unresolved); + Assert.Equal(4, unresolved.Line); + var site = Assert.Single(result.Sites); + Assert.Equal("Probe.cs::ProbeSites.Bound::AppDomain.GetAssemblies", site.Key); + } +} diff --git a/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs b/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs new file mode 100644 index 000000000..43ca0d52e --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Conformance/TpaNamespaceGateTests.cs @@ -0,0 +1,307 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Sharpy.Compiler.Discovery; +using Sharpy.Compiler.Semantic; +using Sharpy.Compiler.Semantic.Registry; +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Compiler.Tests.Conformance; + +/// +/// The two code-read candidates for #2250 cell 1, probed deterministically (P54 Phase 1 task 2) and +/// rewritten against the seam that replaced them (Phase 2 task 1). The violated contract: a compile, +/// and every query on its result, is a function of its inputs alone — the same whichever other +/// compile ran first or runs beside it. +/// +/// Probe 1 — first use of a framework namespace. Before Phase 2, +/// ModuleRegistry._loadedRuntimeNamespaces was a process-wide check-then-act gate with no +/// completion barrier: the first caller's TryAdd won, every later caller returned at once and +/// scanned the process while the winner was still loading the namespace's trusted-platform assemblies +/// (7/7 held askers passed the gate, 7/7 counted 1 type against the solo 9 @ 486c9a88a). Now every +/// CLR-name question answers from the compilation's , whose framework +/// set is a process-constant index built once behind a : there is no per-namespace +/// load left to race. The probe asks for one framework namespace from eight fresh registries released +/// together — the namespace's first use when the class runs alone — and compares every answer with a +/// METADATA census of the framework's trusted-platform files (read with +/// , loading nothing), so a half-built answer is red in any +/// host, not only where the namespace happens to be first-use. +/// +/// Probe 2 — a reference's types are its compilation's alone. Before Phase 2, +/// ClrTypeHelper._originNameDefinitionCache was a static name → Type cache over an +/// AppDomain scan: a compile with no reference resolved a name to the type ANOTHER compile's reference +/// context loaded (8/8), and a null cached by a compile that could not see the name was the answer for +/// a later compile whose reference held it (8/8). Now the name resolves against the asking +/// compilation's visibility, whose caches are per instance and replaced when its own set grows. +/// +/// KnownRed convention. A deterministic red cell is a row: the +/// test asserts the defect REPRODUCES there (so a fix reddens it as "stale — drain this row") and +/// asserts parity in every other cell; each probe has a green positive-control cell. Every row +/// drained in Phase 2 task 1; the cells stay as the class's guard. +/// +[Trait("Category", "Conformance")] +public class TpaNamespaceGateTests +{ + private readonly ITestOutputHelper _output; + + public TpaNamespaceGateTests(ITestOutputHelper output) => _output = output; + + private const string MechanismCell = "tpa-gate.passes-before-completion"; + private const string SoloCell = "tpa-gate.solo-equals-census"; + private const string ConcurrentFirstUseCell = "tpa-gate.concurrent-first-use"; + private const string AfterCompletionCell = "tpa-gate.after-completion"; + private const string ReferenceSeenCell = "origin-cache.reference-seen-by-unreferencing-compile"; + private const string NullPinnedCell = "origin-cache.unresolved-name-pinned-null"; + private const string FrameworkNameCell = "origin-cache.framework-name"; + + private static readonly string[] AllCells = + { + MechanismCell, SoloCell, ConcurrentFirstUseCell, AfterCompletionCell, + ReferenceSeenCell, NullPinnedCell, FrameworkNameCell, + }; + + /// + /// Cells red at base, each asserted to REPRODUCE. Drain a row when its cure lands (the probe + /// then fails the row as stale). Empty since P54 Phase 2 task 1 (#2250). + /// + private static readonly Dictionary KnownRed = new(StringComparer.Ordinal) + { + }; + + private const string GateModule = "microsoft.win32"; + private const string GateNamespace = "Microsoft.Win32"; + private const string GateAssembly = "Microsoft.Win32.Registry"; + private static readonly TimeSpan JoinTimeout = TimeSpan.FromSeconds(60); + + [Fact] + public void KnownRed_RowsNameRealCellsAndCiteTheIssue() + { + foreach (var (cell, reason) in KnownRed) + { + Assert.Contains(cell, AllCells); + Assert.Contains("#2250", reason, StringComparison.Ordinal); + } + Assert.True(AllCells.Count(c => !KnownRed.ContainsKey(c)) >= 2, + "each probe needs a green positive-control cell; the oracle is not 'always red'"); + } + + [Fact] + public void TpaNamespaceGate_ConcurrentFirstUse_IsComplete() + { + var failures = new List(); + + // ---- Oracle: the namespace's public top-level types across the framework's trusted-platform + // files, read as metadata — nothing is loaded, so the oracle cannot be order-dependent. ---- + var census = FrameworkMetadataCensus(GateNamespace); + Assert.True(census.Count > 1, + $"INCONCLUSIVE: the framework census of '{GateNamespace}' found {census.Count} public types — " + + $"'{GateAssembly}' is not a trusted-platform file in this host, so the cell measures nothing"); + var preloaded = IsLoaded(GateAssembly); + + // ---- Eight fresh registries released together: the namespace's first use when the class runs + // alone (the shape the old gate lost: a second asker read a half-loaded process). ---- + var concurrent = AskOnThreads(() => NamespaceAnswer(new ModuleRegistry())); + var solo = NamespaceAnswer(new ModuleRegistry()); + var after = AskOnThreads(() => NamespaceAnswer(new ModuleRegistry())); + + var incomplete = concurrent.Count(a => !a.SetEquals(census)); + var (frameworkAssemblies, frameworkTypes, frameworkMillis) = AssemblyVisibility.FrameworkIndexStatistics; + _output.WriteLine($"framework index: {frameworkAssemblies} assemblies, {frameworkTypes} visible types, " + + $"built in {frameworkMillis} ms (first use in this process)"); + _output.WriteLine($"census={census.Count} ('{GateAssembly}' loaded before the probe: {preloaded}) " + + $"concurrent=[{string.Join(",", concurrent.Select(a => a.Count))}] solo={solo.Count} " + + $"after=[{string.Join(",", after.Select(a => a.Count))}] incomplete={incomplete}/{concurrent.Length}"); + + AssertCell(failures, MechanismCell, diverged: incomplete > 0, + $"{incomplete}/{concurrent.Length} concurrent first-use answers differ from the framework census " + + $"({census.Count} types); first missing: {concurrent.Select(a => census.Except(a).FirstOrDefault()).FirstOrDefault(m => m != null) ?? ""}"); + AssertCell(failures, ConcurrentFirstUseCell, diverged: concurrent.Any(a => !a.SetEquals(solo)), + $"concurrent answers [{string.Join(",", concurrent.Select(a => a.Count))}] vs solo {solo.Count}"); + AssertCell(failures, SoloCell, diverged: !solo.SetEquals(census), + $"solo {solo.Count} vs census {census.Count}"); + AssertCell(failures, AfterCompletionCell, diverged: after.Any(a => !a.SetEquals(solo)), + $"after [{string.Join(",", after.Select(a => a.Count))}] vs solo {solo.Count}"); + + Assert.True(failures.Count == 0, string.Join("\n", failures)); + } + + /// + /// The CLR full names a registry answers for : every exported type symbol, + /// with an arity group (#1613) expanded to all its members. + /// + private static HashSet NamespaceAnswer(ModuleRegistry registry) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var symbol in registry.GetNamespaceTypes(GateModule)) + { + IEnumerable members = symbol.ClrArityGroup is { } group ? group.Values : new[] { symbol }; + foreach (var member in members) + { + if (member.ClrType?.FullName is { } fullName) + names.Add(fullName); + } + } + return names; + } + + /// + /// The public top-level types of declared by the framework's + /// trusted-platform files — the names the shared framework's runtime directory ships, never the + /// host's own packages (#2250) — read from metadata without loading any assembly. + /// + private static HashSet FrameworkMetadataCensus(string namespaceName) + { + var names = new HashSet(StringComparer.Ordinal); + var runtimeDirectory = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory(); + var tpa = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in tpa) + { + var simpleName = Path.GetFileNameWithoutExtension(path); + if (!File.Exists(Path.Combine(runtimeDirectory, simpleName + ".dll"))) + continue; + + using var stream = File.OpenRead(path); + using var pe = new PEReader(stream); + if (!pe.HasMetadata) + continue; + var reader = pe.GetMetadataReader(); + foreach (var handle in reader.TypeDefinitions) + { + var definition = reader.GetTypeDefinition(handle); + if ((definition.Attributes & TypeAttributes.VisibilityMask) != TypeAttributes.Public) + continue; + if (reader.GetString(definition.Namespace) != namespaceName) + continue; + names.Add(namespaceName + "." + reader.GetString(definition.Name)); + } + } + return names; + } + + [Fact] + public void OriginNameCache_CrossCompile_IsNotPoisoned() + { + var failures = new List(); + + // Cell: compile A's reference context holds a type; a compile with no such reference asks + // the same stored name. Its inputs cannot name the type, so the answer must be null. + var nsA = "P54OriginProbeA" + Guid.NewGuid().ToString("N"); + var nameA = nsA + ".Greeter"; + var asmA = LoadIntoOwnContext("P54-compile-A", + $"namespace {nsA} {{ public sealed class Greeter {{ public int Alpha => 1; }} }}"); + var compileA = new AssemblyVisibility(); + compileA.Add(asmA); + var typeA = compileA.ResolveType(nameA); + Assert.True(typeA != null && typeA.Assembly == asmA, + "instrument: compile A must resolve its own reference's type, or the cell measures nothing"); + var seenByB = AskOnThreads(() => new ModuleRegistry().Visibility.ResolveType(nameA)); + var leaked = seenByB.Count(t => t != null && ReferenceEquals(t, typeA)); + _output.WriteLine($"{ReferenceSeenCell}: {leaked}/{seenByB.Length} unreferencing asks returned compile A's Type"); + AssertCell(failures, ReferenceSeenCell, diverged: leaked > 0, + $"{leaked}/{seenByB.Length} asks from a compile with no reference returned '{nameA}' from compile A's context"); + + // Cell: a compile that cannot see a name asks first (a null is cached); then a compile whose + // reference HOLDS the name asks — and so does the first compile once it gains the reference. + // Their inputs name the type, so the answer must be the type. + var nsB = "P54OriginProbeB" + Guid.NewGuid().ToString("N"); + var nameB = nsB + ".Greeter"; + var growing = new AssemblyVisibility(); + Assert.Null(growing.ResolveType(nameB)); + var asmB = LoadIntoOwnContext("P54-compile-B", + $"namespace {nsB} {{ public sealed class Greeter {{ public int Beta => 2; }} }}"); + var truth = asmB.GetType(nameB); + Assert.True(truth != null, "instrument: the referenced assembly must hold the type"); + growing.Add(asmB); + var seenByReferencing = AskOnThreads(() => + { + var fresh = new AssemblyVisibility(); + fresh.Add(asmB); + return fresh.ResolveType(nameB); + }).Append(growing.ResolveType(nameB)).ToArray(); + var pinned = seenByReferencing.Count(t => t != truth); + _output.WriteLine($"{NullPinnedCell}: {pinned}/{seenByReferencing.Length} referencing asks did not return the type"); + AssertCell(failures, NullPinnedCell, diverged: pinned > 0, + $"{pinned}/{seenByReferencing.Length} asks from a compile whose reference holds '{nameB}' did not return it"); + + // Positive control: a framework name is a pure function of its key — every ask agrees. + var framework = AskOnThreads(() => new AssemblyVisibility().ResolveType("System.Collections.Generic.List`1")); + AssertCell(failures, FrameworkNameCell, diverged: framework.Any(t => t != typeof(List<>)), + "a framework generic definition resolved to something other than typeof(List<>)"); + + Assert.True(failures.Count == 0, string.Join("\n", failures)); + } + + /// + /// Records a cell's verdict: a KnownRed row must still reproduce (else it is STALE — drain it), + /// every other cell must not diverge. Collected, so one run reports every cell. + /// + private static void AssertCell(List failures, string cell, bool diverged, string observed) + { + if (KnownRed.TryGetValue(cell, out var row)) + { + if (!diverged) + failures.Add($"STALE KnownRed row '{cell}' — the defect no longer reproduces ({observed}); drain the row. Row: {row}"); + } + else if (diverged) + { + failures.Add($"cell '{cell}' diverged: {observed}"); + } + } + + private static T[] AskOnThreads(Func ask) + { + var results = new T[8]; + var errors = new ConcurrentQueue(); + var start = new Barrier(results.Length); + var threads = Enumerable.Range(0, results.Length).Select(i => new Thread(() => + { + try + { + start.SignalAndWait(); + results[i] = ask(); + } + catch (Exception ex) { errors.Enqueue(ex); } + })).ToList(); + threads.ForEach(t => t.Start()); + foreach (var t in threads) + Assert.True(t.Join(JoinTimeout), "an asking thread did not finish"); + Assert.Empty(errors); + return results; + } + + private static bool IsLoaded(string simpleName) + => AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == simpleName); + + /// + /// Builds a test-only assembly in memory and loads it into its own (non-default) load context — + /// the shape a compile's reference takes once each registry owns its context (Design Decision 2). + /// + private static Assembly LoadIntoOwnContext(string contextName, string source) + { + var assemblyName = contextName + "-" + Guid.NewGuid().ToString("N"); + var runtime = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Single(p => Path.GetFileNameWithoutExtension(p) == "System.Runtime"); + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { CSharpSyntaxTree.ParseText(source) }, + new MetadataReference[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(runtime), + }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var stream = new MemoryStream(); + var emitted = compilation.Emit(stream); + Assert.True(emitted.Success, "the probe assembly must build: " + + string.Join("; ", emitted.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + stream.Position = 0; + return new AssemblyLoadContext(contextName).LoadFromStream(stream); + } +} diff --git a/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt b/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt new file mode 100644 index 000000000..767b793da --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Conformance/in-process-isolation-allowlist.txt @@ -0,0 +1,13 @@ +# In-process isolation sweep allowlist (P54, #2250) — read by InProcessIsolationSweepTests. +# +# One row per red cell: `program sharing threads operation bucket [probabilistic] # #2250 reason; reproduced k/R @ sha` +# program a corpus id (InProcessIsolationCorpus: a named roster id or a fixture test name) +# sharing compiler-per-thread | api-per-thread | api-shared +# threads 1 | 2 | 8 +# operation analyze | compile +# bucket drift (differs from the solo oracle under 2/8 threads) | crash (an exception escaped) +# | order (on ONE thread the warm instance differs from a fresh one) +# +# A red cell without a row fails. A row whose cell was exercised and reproduced in no repetition fails +# as stale — drain it when the fix lands — unless it is marked `probabilistic` (reported, not judged). +# Every row cites #2250; the k/R is the measured reproduction count at the named sha. diff --git a/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs b/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs new file mode 100644 index 000000000..7a3874f01 --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Diagnostics/CrashBundleExclusivityTests.cs @@ -0,0 +1,180 @@ +using System.Globalization; +using Sharpy.Compiler.Diagnostics; +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Compiler.Tests.Diagnostics; + +/// +/// Crash × crash cell of the in-process isolation class (#2250, plan cell 12): every crash gets ONE +/// bundle directory of its own, holding ONE complete report — its own. +/// names a bundle by the clock's millisecond and resolves a collision check-then-create +/// (while (Directory.Exists(candidate)) suffix++, then CreateDirectory, which succeeds on +/// an existing directory), so two writers that read the same free suffix share one directory and the +/// later report.md overwrites the earlier. The internal clock overload pins every writer to one +/// tick, so every write collides on the name and only the suffix scan can separate them. +/// +public sealed class CrashBundleExclusivityTests : IDisposable +{ + private const int BundlesPerWriter = 10; + + /// A writer that stops reading the clock must not hang the others. + private static readonly TimeSpan RoundTimeout = TimeSpan.FromSeconds(30); + + /// The one tick every writer reads — a same-millisecond collision on every write. + private static readonly DateTime OneTick = new(2026, 10, 14, 12, 0, 0, 123, DateTimeKind.Utc); + + private readonly string _tempRoot; + private readonly ITestOutputHelper _output; + + public CrashBundleExclusivityTests(ITestOutputHelper output) + { + _output = output; + _tempRoot = Path.Combine(Path.GetTempPath(), $"sharpy_crash_excl_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempRoot); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_tempRoot)) + Directory.Delete(_tempRoot, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + + /// + /// Writer counts whose cell is a live defect @ base. A row ASSERTS THE DEFECT REPRODUCES, so the + /// fix (Phase 3 task 3: a create-exclusive .owner marker + pid in the name) turns it red as + /// "stale — drain this row". Every other writer count asserts exclusivity. + /// + private static readonly Dictionary KnownRed = new() + { + // Drained in P54 Phase 3 task 3 (create-exclusive .owner marker + pid in the name): the 2- and + // 8-writer rows (1000/1000 and 300/300 red @ 486c9a88a) reported "stale" against the fix. + }; + + [Theory] + [InlineData(1)] // positive control: one writer, ten crashes at one tick → ten directories + [InlineData(2)] + [InlineData(8)] + [Trait("Category", "Conformance")] + public void ConcurrentWriters_AtOneClockTick_EachCrashGetsItsOwnDirectoryAndReport(int writers) + { + var outcome = RunWriters(writers, Path.Combine(_tempRoot, $"w{writers}")); + _output.WriteLine($"writers={writers}: {outcome}"); + + if (KnownRed.TryGetValue(writers, out var reason)) + { + Assert.False(outcome.IsExclusive, + $"Known-red cell writers={writers} is now exclusive — stale, drain its KnownRed row " + + $"({reason}). {outcome}"); + } + else + { + Assert.True(outcome.IsExclusive, $"writers={writers}: {outcome}"); + } + } + + [Fact] + [Trait("Category", "Conformance")] + public void KnownRedRows_CiteTheIssue() + { + foreach (var (_, reason) in KnownRed) + Assert.Contains("#2250", reason, StringComparison.Ordinal); + } + + private static Outcome RunWriters(int writers, string outputDirectory) + { + var results = new (string Marker, string? Path)[writers * BundlesPerWriter]; + using var barrier = new Barrier(writers); + // The clock reads one constant tick AND re-aligns the writers: every TryWrite reads the clock + // once, before it scans for a free suffix, so round b of every writer scans the same directory + // listing. A scheduler skew that lets one writer run ahead cannot serialize the cell green. + using var roundBarrier = new Barrier(writers); + DateTime AlignedOneTick() + { + roundBarrier.SignalAndWait(RoundTimeout); + return OneTick; + } + var threads = new List(); + for (var w = 0; w < writers; w++) + { + var writer = w; + var thread = new Thread(() => + { + barrier.SignalAndWait(); + for (var b = 0; b < BundlesPerWriter; b++) + { + var marker = string.Create(CultureInfo.InvariantCulture, $"exclusivity-marker-w{writer}-b{b}"); + var request = new CrashBundleRequest + { + Producer = marker, + Component = "CrashBundleExclusivityTests", + Exception = new InvalidOperationException(marker), + }; + var path = CrashBundleWriter.TryWrite(outputDirectory, request, AlignedOneTick); + results[(writer * BundlesPerWriter) + b] = (marker, path); + } + }) + { IsBackground = true }; + threads.Add(thread); + thread.Start(); + } + foreach (var thread in threads) + thread.Join(); + + var expected = writers * BundlesPerWriter; + var nullPaths = results.Count(r => r.Path == null); + var distinctPaths = results.Where(r => r.Path != null) + .Select(r => r.Path!).Distinct(StringComparer.Ordinal).Count(); + var crashRoot = Path.Combine(outputDirectory, CrashBundleWriter.CrashRootDirectoryName); + var directoriesOnDisk = Directory.Exists(crashRoot) ? Directory.GetDirectories(crashRoot).Length : 0; + + // A report is the writer's own and complete: it names its own marker as producer AND as the + // exception message (the last section), and no other writer's marker. + var foreignOrIncomplete = 0; + foreach (var (marker, path) in results) + { + if (path == null) + continue; + var reportPath = Path.Combine(path, "report.md"); + var report = File.Exists(reportPath) ? File.ReadAllText(reportPath) : ""; + var ownMarker = report.Contains("- Producer: " + marker + "\n", StringComparison.Ordinal) + && report.Contains("- Message: " + marker + "\n", StringComparison.Ordinal); + var markerCount = CountOccurrences(report, "- Producer: exclusivity-marker-"); + if (!ownMarker || markerCount != 1) + foreignOrIncomplete++; + } + + return new Outcome(expected, nullPaths, distinctPaths, directoriesOnDisk, foreignOrIncomplete); + } + + private static int CountOccurrences(string text, string needle) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + return count; + } + + private sealed record Outcome(int Expected, int NullPaths, int DistinctPaths, int DirectoriesOnDisk, int ForeignOrIncompleteReports) + { + public bool IsExclusive => NullPaths == 0 + && DistinctPaths == Expected + && DirectoriesOnDisk == Expected + && ForeignOrIncompleteReports == 0; + + public override string ToString() + => $"expected {Expected} bundles: {DistinctPaths} distinct returned paths, {DirectoriesOnDisk} directories on disk, " + + $"{NullPaths} null returns, {ForeignOrIncompleteReports} foreign/incomplete reports"; + } +} diff --git a/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs b/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs index de02e4630..bcdd30dc4 100644 --- a/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs +++ b/src/Sharpy.Compiler.Tests/Diagnostics/StaticStateConformanceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Reflection; using System.Runtime.CompilerServices; using Xunit; @@ -19,9 +20,20 @@ namespace Sharpy.Compiler.Tests.Diagnostics; /// (, , ) /// is flagged, because readonly freezes the reference, not the contents — this /// catches _loadedRuntimeNamespaces-style caches that rule 1 misses. +/// (2b, #2250) A static readonly PROCESS-WIDE CACHE — , +/// , , including the backing field of a +/// static auto-property — needs a row. These were once exempt by design +/// ("their contents are a deterministic function of the key"); that premise is only as good as the key, and a +/// name-keyed cache whose value is resolved against the process's loaded assemblies pins whoever asked first +/// for every later compile. So each row states its key-purity justification, and the census is anchored to a +/// literal count. /// /// -/// Both rules consult a single keyed by declaring-type + field name. +/// Every rule reads a static auto-property's backing field as the property it stands for — keyed by +/// the property name — so static int Counter { get; set; } is rule 1's and +/// static Dictionary<K, V> Map { get; } = new(); rule 2's, exactly like the fields they hide. +/// +/// Rules 1 and 2 consult a single keyed by declaring-type + field name. /// Every entry carries a justification: either an init-once lookup table (populated at /// declaration/static-ctor and never mutated afterward, so effectively immutable) or a /// consciously-reviewed runtime-mutable field whose mutation is determinism-safe. Adding a @@ -45,21 +57,8 @@ public StaticStateConformanceTests(ITestOutputHelper output) /// → justification. Keep this list minimal and justified; new entries mean a new static /// mutable field was introduced and must be reviewed for determinism safety. /// - // Note on ConcurrentDictionary caches: ClrTypeHelper._paramsIndexerCache is a - // static readonly ConcurrentDictionary used as an idempotent cache keyed by CLR type. - // It is intentionally NOT flagged (rule 2 excludes ConcurrentDictionary) and needs no - // allowlist entry: its contents are a deterministic function of the key. - // - // The D2 warm-compile caches added for #1049 are the same shape and, likewise, need no - // keyed entry (a keyed entry would go stale since the field is never flagged) — they are - // recorded here so their determinism review is conscious and visible: - // * AssemblyCompiler.s_referenceCache — MetadataReference keyed by (assembly path, - // last-write time UTC). A reference is a pure function of the file's bytes; a changed - // assembly gets a new mtime and thus a new key. Content-addressed, append-only. - // * OverloadIndexCache.s_inMemoryIndices — deserialized OverloadIndex keyed by the full - // cache-file path, which embeds the content-addressed AssemblyIdentity cache key - // (name-version-hash). A changed assembly hashes to a different key. Content-addressed, - // append-only; ClearAll evicts the directory's entries so the disk-clear path stays honest. + // Process-wide caches (ConcurrentDictionary / Lazy / ConditionalWeakTable) are rule 2b's, + // rostered in ProcessWideCacheRoster below — no longer exempt by type (#2250). private static readonly IReadOnlyDictionary<(string Type, string Field), string> Allowlist = new Dictionary<(string, string), string> { @@ -125,12 +124,108 @@ public StaticStateConformanceTests(ITestOutputHelper output) "typing guidance-message table, initialized inline and read-only.", }; + private const string TypeIdentity = + " A Type is an identity, so the value is a function of the key; P54 Phase 2 gates GetOrAdd on types " + + "wholly in the default load context, so a per-registry type is answered uncached and never pins its " + + "context (Design Decision 3)."; + + /// + /// Rule 2b roster (#2250): every static process-wide cache in the compiler assembly, with why its value is a + /// function of its key alone. Rows citing #2250 are NOT key-pure — their value depends on what the process + /// has loaded — and drain when P54 Phase 2 moves them into the compilation's registry; every other row is + /// permanent. + /// + private static readonly IReadOnlyDictionary<(string Type, string Field), string> ProcessWideCacheRoster = + new Dictionary<(string, string), string> + { + // ---- Content-addressed (D2 warm-compile caches, #1049) ---- + [("Sharpy.Compiler.AssemblyCompiler", "s_referenceCache")] = + "MetadataReference keyed by (assembly path, last-write time UTC): a pure function of the file's " + + "bytes; a changed assembly gets a new mtime and thus a new key. Append-only.", + [("Sharpy.Compiler.Discovery.Caching.OverloadIndexCache", "s_inMemoryIndices")] = + "Deserialized OverloadIndex keyed by the cache-file path, which embeds the content-addressed " + + "AssemblyIdentity key (name-version-hash); a hit is re-validated against the identity. ClearAll " + + "evicts the directory's entries.", + [("Sharpy.Compiler.Shared.CompilerIdentity", "s_version")] = + "Lazy with no key: the compiler assembly's own version + SHA-256, a process constant.", + + // ---- Fixed framework surface ---- + [("Sharpy.Compiler.Discovery.ClrExtensionMethodResolver", "_byName")] = + "Lazy index over the fixed SurfaceTypes = { typeof(Enumerable) } — a framework type, a process " + + "constant; no key.", + [("Sharpy.Compiler.Discovery.ClrExtensionMethodResolver", "_ancestorCache")] = + "Type -> its base chain + interfaces, read off the Type alone." + TypeIdentity, + + // ---- Type-keyed reflection memos ---- + [("Sharpy.Compiler.Discovery.BuiltinExceptionSurface", "_typeCache")] = + "Type -> is a builtin exception (Exception-derived, Sharpy.Core assembly, Sharpy namespace), read " + + "off the Type alone." + TypeIdentity, + [("Sharpy.Compiler.Discovery.BuiltinExceptionSurface", "_refusedCache")] = + "(Type, member name) -> refused System.* member, by reflection over the Type alone." + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeBridge", "_mapsToSharpyBuiltinCache")] = + "Generic definition -> collapsed onto a Sharpy builtin, probed through the bridge's own arm table " + + "keyed on the definition's FullName (ClrTypeBridge is parameterless — no registry input)." + + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_paramsIndexerCache")] = + "Type -> has a params indexer, by reflection over the Type alone." + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_clrMethodNameCache")] = + "(Type, Sharpy member name) -> CLR method name via the pure NameMangler reverse map." + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_clrPropertyNameCache")] = + "(Type, Sharpy member name) -> CLR property name via the pure NameMangler reverse map." + + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_originDefinitionCache")] = + "(Type, definition full name) -> that definition in the Type's own supertype closure." + + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_genericInstanceMethodCache")] = + "(Type, Sharpy member name) -> generic method definitions on the Type." + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_memberNameSurfaceCache")] = + "Type -> every member name it and its interfaces declare." + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_overridableMemberCache")] = + "(Type, Sharpy method name, arity) -> overridable member kind on the Type." + TypeIdentity, + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_extensionMethodNameCache")] = + "Assembly -> names of the extension methods it exports. An Assembly is an identity, so the value " + + "is a function of the key; Phase 2's default-context gate applies to Assembly keys as to Type " + + "keys (Design Decision 3).", + [("Sharpy.Compiler.Discovery.ClrTypeHelper", "_extensionMethodsByVerbatimNameCache")] = + "Assembly -> its extension methods by verbatim CLR name. An Assembly is an identity, so the value " + + "is a function of the key; Phase 2's default-context gate applies to Assembly keys as to Type " + + "keys (Design Decision 3).", + + // ---- The per-reference-set load contexts (#2250, P54 Design Decision 2) ---- + [("Sharpy.Compiler.Discovery.ReferenceLoadContext", "s_shared")] = + "Reference-set key (each reference file's full path + length + last-write time, the earlier " + + "references' identities, the module paths — AssemblyCompiler.s_referenceCache's convention) -> " + + "WeakReference to the context loaded for it: the context's contents are a function of the files " + + "its key names, so equal keys may share it; held weakly so it unloads when no registry uses it, " + + "and dead entries are pruned on every creation.", + + // ---- The visibility seam's framework index (#2250, P54 Design Decision 1) ---- + [("Sharpy.Compiler.Discovery.AssemblyVisibility", "s_framework")] = + "Lazy with no key: the framework set — the trusted-platform System.*/Microsoft.*/" + + "netstandard/mscorlib assemblies, Sharpy.Core, the compiler and its direct dependencies — loaded " + + "and indexed once; the trusted-platform list is fixed at process start, so the value is a process " + + "constant. ExecutionAndPublication: a reader blocks until the index is complete. The five " + + "name-keyed rows it replaced (_originNameDefinitionCache, _displayNameDefinitionCache, " + + "s_typePresentCache, _probedNamespaces, _loadedRuntimeNamespaces) drained into per-visibility state.", + }; + [Fact] public void CompilerAssembly_HasNoUnlistedMutableStaticState() { var assembly = typeof(Sharpy.Compiler.Compiler).Assembly; var violations = new List(); var matchedAllowlistKeys = new HashSet<(string, string)>(); + var flaggedCaches = new List<(string, string)>(); + + void CheckProcessWideCache((string Type, string Field) cacheKey, Type fieldType) + { + flaggedCaches.Add(cacheKey); + if (!ProcessWideCacheRoster.ContainsKey(cacheKey)) + { + violations.Add( + $"[rule 2b: process-wide cache] {cacheKey.Type}.{cacheKey.Field} : {FriendlyType(fieldType)}"); + } + } foreach (var type in assembly.GetTypes()) { @@ -142,12 +237,26 @@ public void CompilerAssembly_HasNoUnlistedMutableStaticState() foreach (var field in type.GetFields( BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) { - if (IsCompilerGenerated(field) || field.IsLiteral && field.FieldType.IsEnum) + // A static auto-property's backing field is compiler-generated, but `static int Counter + // { get; set; }` is a mutable static exactly like the field it stands for, and + // `static Dictionary Map { get; } = new();` a mutable static collection: every rule + // applies to it, keyed by the PROPERTY name (#1036, #2250). Any other compiler-generated + // field (a lambda cache, a fixed buffer) is the compiler's, not ours. + var memberName = field.Name; + if (IsCompilerGenerated(field)) + { + if (!TryGetAutoPropertyName(field, out memberName)) + { + continue; + } + } + + if (field.IsLiteral && field.FieldType.IsEnum) { continue; } - var key = (type.FullName ?? type.Name, field.Name); + var key = (type.FullName ?? type.Name, memberName); var allowlisted = Allowlist.ContainsKey(key); // Rule 1: static fields must be readonly or const. @@ -160,7 +269,7 @@ public void CompilerAssembly_HasNoUnlistedMutableStaticState() else { violations.Add( - $"[rule 1: not readonly/const] {key.Item1}.{field.Name} : {FriendlyType(field.FieldType)}"); + $"[rule 1: not readonly/const] {key.Item1}.{memberName} : {FriendlyType(field.FieldType)}"); } continue; } @@ -175,9 +284,15 @@ public void CompilerAssembly_HasNoUnlistedMutableStaticState() else { violations.Add( - $"[rule 2: mutable static collection] {key.Item1}.{field.Name} : {FriendlyType(field.FieldType)}"); + $"[rule 2: mutable static collection] {key.Item1}.{memberName} : {FriendlyType(field.FieldType)}"); } } + + // Rule 2b: static readonly process-wide caches must be rostered (#2250). + if (field.IsInitOnly && IsProcessWideCache(field.FieldType)) + { + CheckProcessWideCache(key, field.FieldType); + } } } @@ -201,6 +316,64 @@ public void CompilerAssembly_HasNoUnlistedMutableStaticState() string.Join("\n ", staleEntries .OrderBy(k => $"{k.Item1}.{k.Item2}", StringComparer.Ordinal) .Select(k => $"{k.Item1}.{k.Item2}"))); + + var staleCacheRows = ProcessWideCacheRoster.Keys.Where(k => !flaggedCaches.Contains(k)).ToList(); + Assert.True(staleCacheRows.Count == 0, + "Stale ProcessWideCacheRoster rows (no matching static cache — drained or renamed; remove them):\n " + + string.Join("\n ", staleCacheRows + .OrderBy(k => $"{k.Type}.{k.Field}", StringComparer.Ordinal) + .Select(k => $"{k.Type}.{k.Field}"))); + + // Literal census @ 486c9a88a (measured): 20 ConcurrentDictionary + 2 Lazy, 0 ConditionalWeakTable. + // P54 Phase 2 task 1 (measured): the five name-keyed rows drained (-5) and the framework index Lazy + // was rostered (+1): 15 ConcurrentDictionary + 3 Lazy = 18. + // P54 Phase 2 task 2 (measured): + ReferenceLoadContext.s_shared = 19. + Assert.Equal(19, flaggedCaches.Count); + } + + /// + /// Rule 2b's type test, per arm — the census has no ConditionalWeakTable today, so each arm is pinned + /// here rather than trusted (positive and negative controls). + /// + [Theory] + [InlineData(typeof(ConcurrentDictionary), true)] + [InlineData(typeof(Lazy), true)] + [InlineData(typeof(ConditionalWeakTable), true)] + [InlineData(typeof(System.Collections.Immutable.ImmutableDictionary), false)] + [InlineData(typeof(System.Collections.Frozen.FrozenSet), false)] + [InlineData(typeof(Dictionary), false)] + [InlineData(typeof(string), false)] + public void IsProcessWideCache_ClassifiesEachArm(Type type, bool expected) + { + Assert.Equal(expected, IsProcessWideCache(type)); + } + + private static bool IsProcessWideCache(Type type) + { + if (!type.IsGenericType) + { + return false; + } + + var def = type.GetGenericTypeDefinition(); + return def == typeof(ConcurrentDictionary<,>) + || def == typeof(Lazy<>) + || def == typeof(ConditionalWeakTable<,>); + } + + private static bool TryGetAutoPropertyName(FieldInfo field, out string propertyName) + { + // `k__BackingField` + const string suffix = ">k__BackingField"; + var name = field.Name; + if (name.StartsWith('<') && name.EndsWith(suffix, StringComparison.Ordinal)) + { + propertyName = name.Substring(1, name.Length - 1 - suffix.Length); + return true; + } + + propertyName = string.Empty; + return false; } private static bool IsMutableCollection(Type type) @@ -214,8 +387,8 @@ private static bool IsMutableCollection(Type type) return def == typeof(HashSet<>) || def == typeof(Dictionary<,>) || def == typeof(List<>); - // ConcurrentDictionary<,> and immutable/frozen/read-only collection types are - // intentionally not treated as mutable here. + // ConcurrentDictionary<,> / Lazy<> / ConditionalWeakTable<,> are rule 2b's (IsProcessWideCache); + // immutable/frozen/read-only collection types are intentionally not treated as mutable here. } private static bool IsCompilerGenerated(MemberInfo member) => @@ -253,16 +426,18 @@ private static string FriendlyType(Type type) "Dictionary — builtin type cache (#1633)", [("Sharpy.Compiler.Semantic.Registry.BuiltinRegistry", "_functions")] = "Dictionary> — builtin function cache (#1633)", - [("Sharpy.Compiler.Semantic.Registry.BuiltinRegistry", "_pendingInterfacePopulation")] = - "List<(TypeSymbol, TypeParameterType[])> — deferred interface work (#1633)", [("Sharpy.Compiler.Semantic.Registry.BuiltinRegistry", "_interfaceSymbols")] = - "Dictionary — interface definition cache (#1633)", + "ConcurrentDictionary — interface definition cache, GetOrAdd with a value that is a " + + "function of the CLR definition alone; its symbols are frozen with the registry (#1633, #2250)", [("Sharpy.Compiler.Discovery.CachedModuleDiscovery", "_moduleTypeSymbols")] = "ConcurrentDictionary — module type symbol cache (#1633)", [("Sharpy.Compiler.Discovery.ClrTypeBridge", "_interfaceSymbolCache")] = "ConcurrentDictionary — interface definition cache (#1633)", [("Sharpy.Compiler.Semantic.Registry.BuiltinRegistry", "_clrTypeCache")] = "ConcurrentDictionary<(string, int), TypeSymbol?> — CLR type lookup cache (#1633)", + [("Sharpy.Compiler.Semantic.SymbolOverloadTable", "_table")] = + "Dictionary> — not a cache: a TypeSymbol's own overload table " + + "(OperatorMethods/ProtocolMethods/MethodOverloads), which refuses every write once its symbol is frozen (#2250)", }; [Fact] diff --git a/src/Sharpy.Compiler.Tests/Discovery/AttributeAbsenceVerdictScopeTests.cs b/src/Sharpy.Compiler.Tests/Discovery/AttributeAbsenceVerdictScopeTests.cs index 4d771dd3f..bdff80ae1 100644 --- a/src/Sharpy.Compiler.Tests/Discovery/AttributeAbsenceVerdictScopeTests.cs +++ b/src/Sharpy.Compiler.Tests/Discovery/AttributeAbsenceVerdictScopeTests.cs @@ -21,6 +21,12 @@ namespace Sharpy.Compiler.Tests.Discovery; /// The shape below is the LSP's: probe (miss), an assembly arrives, probe again in a NEW /// compilation. Only the second probe's verdict is under test — within one compilation a stable /// answer is correct and desirable. +/// +/// #2250: an assembly "arrives" for a compilation only as one of ITS references — the +/// resolver answers from the compilation's , never from whatever the +/// process has loaded. So the assembly arrives here as the second compilation's reference, and a +/// third compilation WITHOUT that reference must still not see it (the isolation half; before #2250 +/// it did, through a process-wide presence cache and an AppDomain scan). /// public class AttributeAbsenceVerdictScopeTests { @@ -67,15 +73,14 @@ public sealed class {attributeName} : System.Attribute {{ }} [Fact] public void AnAbsenceVerdict_DoesNotSurviveIntoTheNextCompilation() { - // A namespace unique to this run: a second run in the same process must not inherit the - // first's PRESENCE verdict, which is process-wide by design. + // A namespace unique to this run, so no other test's assembly can declare the type. var tag = Guid.NewGuid().ToString("N"); var ns = $"Sharpy.Probe.N{tag}"; var attribute = "WidgetAttribute"; var fullName = $"{ns}.{attribute}"; // --- Compilation 1: the type genuinely does not exist yet. The miss is recorded. - var first = new ClrAttributeResolver(); + var first = new ClrAttributeResolver(new AssemblyVisibility()); first.ResolvesToClrType(fullName).Should().BeFalse( "nothing declares this type yet — if this were true the assembly name is not unique " + "and the rest of the cell is meaningless"); @@ -85,9 +90,8 @@ public void AnAbsenceVerdict_DoesNotSurviveIntoTheNextCompilation() first.ResolvesToClrType(fullName).Should().BeFalse( "within one compilation the answer is memoized and stable"); - // --- The event the old premise did not cover: an assembly arrives from somewhere other - // than this class's own framework loading, exactly as ModuleRegistry.LoadReference - // brings in a .spyproj reference. + // --- The event the old premise did not cover: an assembly arrives as a reference, exactly + // as ModuleRegistry.LoadReference brings in a .spyproj reference. var loaded = Assembly.Load(EmitAttributeAssembly(ns, attribute, $"SharpyProbe{tag}")); loaded.GetType(fullName).Should().NotBeNull( "the specimen assembly must really declare the type, or the 'after' probe would be " @@ -95,22 +99,31 @@ public void AnAbsenceVerdict_DoesNotSurviveIntoTheNextCompilation() // --- Compilation 2: a fresh resolver, as ValidationPipelineFactory builds a fresh // DecoratorValidator per compilation. - var second = new ClrAttributeResolver(); + var referencing = new AssemblyVisibility(); + referencing.Add(loaded); + var second = new ClrAttributeResolver(referencing); var verdict = second.ResolvesToClrType(fullName); - _output.WriteLine($"{fullName}: before=False, after={verdict}"); + // --- Compilation 3: a fresh compilation WITHOUT the reference. The assembly is in the + // process, but not in this compilation's inputs — it must not be seen (#2250). + var unreferencing = new ClrAttributeResolver(new AssemblyVisibility()).ResolvesToClrType(fullName); + + _output.WriteLine($"{fullName}: before=False, after(referencing)={verdict}, after(unreferencing)={unreferencing}"); + + unreferencing.Should().BeFalse( + "a compilation sees only its own references: another compilation's (or the process's) load " + + "must not change its answer (#2250)"); verdict.Should().BeTrue( - "the type exists now, so the next compilation must see it. While the absence verdict " + "the type exists in this compilation's reference, so it must see it. While the absence verdict " + "was process-lifetime this stayed false until the process restarted — a stale " + "SPY0495 that no edit could clear (#1493)"); } /// - /// The other half of the contract, so the fix cannot be "scope everything and re-reflect - /// forever": a PRESENCE verdict is still shared across compilations. Types do not unload, so - /// that verdict can never go stale, and keeping it process-wide is what stops the hot path - /// from paying for the fix. + /// The other half of the contract: a FRAMEWORK type is present for every compilation — the + /// framework set is a process constant every visibility shares (#2250), so two compilations + /// agree on it without either loading anything. /// [Fact] public void APresenceVerdict_IsStillSharedAcrossCompilations() @@ -118,9 +131,9 @@ public void APresenceVerdict_IsStillSharedAcrossCompilations() // Guaranteed loaded and guaranteed present. const string known = "System.ObsoleteAttribute"; - new ClrAttributeResolver().ResolvesToClrType(known).Should().BeTrue( + new ClrAttributeResolver(new AssemblyVisibility()).ResolvesToClrType(known).Should().BeTrue( "System.ObsoleteAttribute exists in any process that got this far"); - new ClrAttributeResolver().ResolvesToClrType(known).Should().BeTrue( + new ClrAttributeResolver(new AssemblyVisibility()).ResolvesToClrType(known).Should().BeTrue( "and a second compilation must agree — presence is monotonic"); } } diff --git a/src/Sharpy.Compiler.Tests/Discovery/LoadedAssemblyNameIsolationTests.cs b/src/Sharpy.Compiler.Tests/Discovery/LoadedAssemblyNameIsolationTests.cs index 4b0f05305..c4b1f27b6 100644 --- a/src/Sharpy.Compiler.Tests/Discovery/LoadedAssemblyNameIsolationTests.cs +++ b/src/Sharpy.Compiler.Tests/Discovery/LoadedAssemblyNameIsolationTests.cs @@ -9,7 +9,7 @@ namespace Sharpy.Compiler.Tests.Discovery; /// /// One assembly in the process that cannot describe itself must not fail a compilation (#1840). -/// ClrAttributeResolver enumerates the loaded assemblies and reads each simple name; a +/// AssemblyVisibility reads the simple name of every assembly it indexes or resolves by name; a /// transient assembly whose file has been replaced throws /// ("Index not found") from , and that exception escaped into /// DecoratorValidator as an internal compiler error — nondeterministically, depending on what @@ -66,7 +66,7 @@ public void AnUnreadableAssembly_IsSkipped_AndTheReadableOnesAreStillCollected(s new NamedAssembly("After.Unreadable") }; - var names = ClrAttributeResolver.LoadedSimpleNames(assemblies); + var names = AssemblyVisibility.SafeSimpleNames(assemblies); // The isolation itself: no exception escapes. `label` names the injected fault in the // failure message, so a red says WHICH exception type the guard let through. @@ -93,12 +93,12 @@ public void TheInjectedFault_IsReal() /// /// The real process's assemblies still produce a non-empty set: a guard that swallowed everything /// would satisfy the injection test and silently stop the namespace-to-assembly matching that - /// EnsureFrameworkAssembliesLoaded depends on. + /// the visibility's by-name assembly lookup depends on. /// [Fact] public void TheProcessAssemblies_StillAnswer() { - var names = ClrAttributeResolver.LoadedSimpleNames(AppDomain.CurrentDomain.GetAssemblies()); + var names = AssemblyVisibility.SafeSimpleNames(AppDomain.CurrentDomain.GetAssemblies()); Assert.NotEmpty(names); Assert.Contains("Sharpy.Compiler", names); diff --git a/src/Sharpy.Compiler.Tests/ProcessGlobalCollections.cs b/src/Sharpy.Compiler.Tests/ProcessGlobalCollections.cs index 62a659f22..b0769178a 100644 --- a/src/Sharpy.Compiler.Tests/ProcessGlobalCollections.cs +++ b/src/Sharpy.Compiler.Tests/ProcessGlobalCollections.cs @@ -34,3 +34,13 @@ public class ConsoleCaptureCollection public class PropertySerialCollection { } + +/// +/// Tests that force full garbage collections to measure what a long-lived host retains (the +/// per-registry load contexts of #2250): a forced collection stalls every concurrently running test, +/// so these run alone (#2254). +/// +[CollectionDefinition("LoadContextRetention", DisableParallelization = true)] +public class LoadContextRetentionCollection +{ +} diff --git a/src/Sharpy.Compiler.Tests/Project/MasterRegistryImmutabilityTests.cs b/src/Sharpy.Compiler.Tests/Project/MasterRegistryImmutabilityTests.cs index 383e4660c..a9a0f483a 100644 --- a/src/Sharpy.Compiler.Tests/Project/MasterRegistryImmutabilityTests.cs +++ b/src/Sharpy.Compiler.Tests/Project/MasterRegistryImmutabilityTests.cs @@ -5,8 +5,12 @@ using System.Text; using Sharpy.Compiler.Semantic; using Sharpy.Compiler.Semantic.Registry; +using Sharpy.TestInfrastructure.Formatting; +using Sharpy.TestInfrastructure.Integration; +using Sharpy.TestInfrastructure.Registry; using Xunit; using Xunit.Abstractions; +using SAst = Sharpy.Compiler.Parser.Ast; namespace Sharpy.Compiler.Tests.Project; @@ -181,9 +185,16 @@ public void Snapshot_DetectsAMutationOfAMasterSymbol() var len = master.GetFunction("len"); Assert.NotNull(len); var original = len!.Documentation; + + // The setter refuses a write to a frozen master symbol (#2250), so the control writes the + // property's backing field directly — the instrument must still see what a writer that got + // past the freeze would have done. + var backing = typeof(Symbol).GetField($"<{nameof(Symbol.Documentation)}>k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(nameof(Symbol), $"<{nameof(Symbol.Documentation)}>k__BackingField"); try { - len.Documentation = "poisoned by the positive control"; + backing.SetValue(len, "poisoned by the positive control"); var mutations = snapshot.FindMutations(api); Assert.NotEmpty(mutations); @@ -195,7 +206,7 @@ public void Snapshot_DetectsAMutationOfAMasterSymbol() } finally { - len.Documentation = original; + backing.SetValue(len, original); } // ...and the same comparison is clean once the mutation is undone, so the control is @@ -249,6 +260,810 @@ public void SequentialReuse_MixedPaths_Succeeds() #endregion + #region Dictionary key sets across the handler-query / applied-text request sequence (P54, #2250) + + /// + /// The P47 stems (#2250 cells 2–3, issuecomment-6120931669) whose Extract-method output moved with what the same + /// had served before; each is run as its identity and lone-\r twin. + /// + private static readonly string[] P47Stems = + { + "fstrings/format_iformattable_class_1988", + "fstrings/format_no_dunder_format_refused_1988", + "deferred_overload_annotated_pin_1589", + }; + + /// + /// KnownRed rows (P54 Phase 1 convention): the instance key sets of measured to + /// GROW on a second, identical pass of the request sequence after a full warm-up — keyed by set path, each citing + /// #2250 with its measured reproduction. A row asserts the growth reproduces (the Phase 2 task 3 freeze turns it + /// red as stale: delete the row); a set that grows with no row is red. + /// + private static readonly IReadOnlyDictionary KnownGrowingKeySets = new Dictionary(StringComparer.Ordinal) + { + }; + + /// + /// KnownRed rows: master symbol properties the request sequence WRITES (symbol path + property, as the + /// names them). Deterministic — each asserts the write reproduces. Empty since P54 + /// Phase 2 task 3: the #2250 cell-2 seam — analysing a text (a builtin's name + /// defined inside a try: body) rewrote the master builtin's Parameters — is cured (the def declares + /// its own symbol) and the master's symbols are frozen. + /// + private static readonly IReadOnlyDictionary KnownWrittenMasterProperties = new Dictionary(StringComparer.Ordinal) + { + }; + + /// + /// #2250 Design Decision 4: after ONE full pass of the P47 request sequence — LSP-route analysis + /// ( with , a fresh document path + /// per open, as the workspace does), the semantic queries a code-action/hover handler makes on that analysis + /// (), the analysis of the Extract-method APPLIED text, and the path-less source + /// analysis the P47 sweep's oracle ran — the master registries' dictionary key sets and every master symbol property + /// must be a fixed point: a second identical pass may not add a key or write a property. Between every step of the + /// second pass the first diverging key is printed (the seam measurement for cells 2–3). + /// + [Fact] + public void MasterKeySets_AreAFixedPointAfterWarmUp_AcrossAnalyzeHandlerQueriesAndAppliedTextAnalyze() + { + var api = new CompilerApi(null, StdlibReferencePaths()); + var master = api.PrimeAnalysisContextForTests(); + var baseline = MasterRegistryKeySets.Take(api); + Assert.True(baseline.InstanceKeyCount >= 50, + $"the key-set snapshot saw only {baseline.InstanceKeyCount} entries in the master's dictionaries — it is not " + + "reading the registry and every comparison below would pass vacuously"); + + // Symbol properties are never a target, warm or not (#1633): compared from the baseline, before the first pass. + var symbols = new RegistrySnapshot(api); + symbols.AssertNotVacuous(); + var programs = RequestSequencePrograms(); + foreach (var (label, step) in RequestSequence(api, programs, pass: 1)) + step(); + AssertSameMaster(api, master, "the warm-up pass"); + var warm = MasterRegistryKeySets.Take(api); + _output.WriteLine($"warm-up pass grew the masters:\n{baseline.Describe(warm)}"); + + var previous = warm; + var steps = 0; + foreach (var (label, step) in RequestSequence(api, programs, pass: 2)) + { + step(); + var now = MasterRegistryKeySets.Take(api); + var first = previous.InstanceDiffTo(now); + _output.WriteLine($"{label}: {(first.Count == 0 ? "no instance key-set change" : first[0].ToString())}"); + previous = now; + steps++; + } + + AssertSameMaster(api, master, "the second pass"); + Assert.True(steps >= 4 * programs.Count, $"the second pass ran only {steps} steps"); + var grown = warm.InstanceDiffTo(previous); + _output.WriteLine($"second pass, instance sets:\n{warm.Describe(previous)}"); + var mutations = symbols.FindMutations(api); + _output.WriteLine($"both passes, master symbol properties: {mutations.Count} written, " + + $"{symbols.AppearedSinceBaseline} symbols appeared\n{Format(mutations)}"); + + var problems = new List(); + var written = mutations.Select(m => $"{m.SymbolPath}.{m.Property}").ToHashSet(StringComparer.Ordinal); + foreach (var property in written.Where(p => !KnownWrittenMasterProperties.ContainsKey(p))) + problems.Add($"{property} — a master symbol property was written (no KnownRed row):\n{Format(mutations)}"); + foreach (var (property, row) in KnownWrittenMasterProperties) + { + if (!written.Contains(property)) + problems.Add($"{property} is no longer written — stale KnownRed row ({row}): delete it"); + } + + foreach (var divergence in grown) + { + if (!KnownGrowingKeySets.ContainsKey(divergence.Path)) + problems.Add($"{divergence} — a master dictionary grew on a pass that repeats one it has already served (no KnownRed row)"); + } + + foreach (var (path, row) in KnownGrowingKeySets) + { + if (grown.All(d => d.Path != path)) + problems.Add($"{path} no longer grows on the second pass — stale KnownRed row ({row}): delete it"); + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + /// + /// Positive control for the key-set instrument: a deliberate write to one master dictionary + /// (BuiltinRegistry._interfaceSymbols, made here by reflection) must be named by the comparison — and the + /// comparison must be clean again once it is undone. A missing field is a from + /// , so a rename is red too. + /// + [Fact] + public void KeySetSnapshot_DetectsAWriteToAMasterDictionary() + { + var api = new CompilerApi(null, StdlibReferencePaths()); + var master = api.PrimeAnalysisContextForTests(); + var before = MasterRegistryKeySets.Take(api); + var field = typeof(BuiltinRegistry).GetField("_interfaceSymbols", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingFieldException(nameof(BuiltinRegistry), "_interfaceSymbols"); + var table = (System.Collections.Concurrent.ConcurrentDictionary)field.GetValue(master)!; + var key = typeof(IKeySetProbe); + Assert.False(table.ContainsKey(key)); + Assert.True(table.TryAdd(key, new TypeSymbol { Name = "IKeySetProbe", Kind = SymbolKind.Type })); + try + { + var after = MasterRegistryKeySets.Take(api); + var divergences = before.InstanceDiffTo(after); + _output.WriteLine(before.Describe(after)); + Assert.Contains(divergences, d => d.Path == "BuiltinRegistry._interfaceSymbols" + && d.Added.Any(k => k.StartsWith(key.FullName!, StringComparison.Ordinal))); + Assert.StartsWith("BuiltinRegistry._interfaceSymbols", before.FirstDivergence(after), StringComparison.Ordinal); + } + finally + { + table.TryRemove(key, out _); + } + + Assert.Empty(before.InstanceDiffTo(MasterRegistryKeySets.Take(api))); + } + + /// + /// KnownRed rows for : the builtin names a + /// later document reads with a different type after a text was analysed on the + /// same instance (deterministic; the row asserts the retype reproduces). Empty since P54 Phase 2 task 3. + /// + private static readonly IReadOnlyDictionary KnownRetypedBuiltins = new Dictionary(StringComparer.Ordinal) + { + }; + + /// + /// The compiler-level twin of #2250 cell 2: on ONE , a later document's builtin identifiers + /// must have the type they have on a fresh instance after the texts — a module + /// def len and a method format declared inside a try: body — were analysed. The master's key sets + /// and symbol properties across the triggers are printed (the seam). print is the positive control: no trigger + /// shadows it, so it must read the same. + /// + [Fact] + public void WarmInstanceTriggers_DoNotRetypeABuiltinForALaterDocument() + { + const string probe = "def main() -> None:\n xs: list[int] = [1, 2]\n n: int32 = len(xs)\n s: str = format(1.5, \".1f\")\n print(s)\n"; + var builtins = new[] { "len", "format", "print" }; + var cold = BuiltinIdentifierTypes(new CompilerApi(null, StdlibReferencePaths()), probe, builtins); + Assert.Equal(builtins.Length, cold.Count); + + var api = new CompilerApi(null, StdlibReferencePaths()); + api.PrimeAnalysisContextForTests(); + var keySets = MasterRegistryKeySets.Take(api); + var symbols = new RegistrySnapshot(api); + symbols.AssertNotVacuous(); + Assert.Equal(cold, BuiltinIdentifierTypes(api, probe, builtins)); + foreach (var (name, text) in WarmInstanceTriggers.AllTexts) + { + api.Analyze(text, CancellationToken.None); + var now = MasterRegistryKeySets.Take(api); + _output.WriteLine($"after {name}: key sets {keySets.FirstDivergence(now)}"); + keySets = now; + } + + var mutations = symbols.FindMutations(api); + _output.WriteLine($"master symbol properties written across the triggers: {mutations.Count} " + + $"({symbols.ComparedProperties} compared over {symbols.ComparedSymbols} symbols)\n{Format(mutations)}"); + var warm = BuiltinIdentifierTypes(api, probe, builtins); + var problems = new List(); + foreach (var name in builtins) + { + var line = $"{name}: cold {cold[name]} / warm {warm[name]}"; + _output.WriteLine(line); + var retyped = cold[name] != warm[name]; + if (KnownRetypedBuiltins.TryGetValue(name, out var row)) + { + if (!retyped) + problems.Add($"{line} — no longer retyped: stale KnownRed row ({row}), delete it"); + } + else if (retyped) + { + problems.Add($"{line} — a later document's builtin was retyped by an earlier analysis (no KnownRed row)"); + } + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + /// + /// The seam of #2250 cell 2, asserted where it lives: each builtin-named def of a + /// text — a module def len and a method format inside a try: body, which NameResolver does not + /// declare — declares its OWN symbol, carrying its own signature, never the master builtin of the same name. Before + /// P54 Phase 2 task 3 the definition resolved by name to the master symbol and wrote its signature onto it. + /// + [Fact] + public void WarmInstanceTriggers_EachBuiltinNamedDefDeclaresItsOwnSymbol() + { + var api = new CompilerApi(null, StdlibReferencePaths()); + var master = api.PrimeAnalysisContextForTests(); + var checkedDefs = 0; + foreach (var (name, _, text) in WarmInstanceTriggers.AppliedTexts) + { + var result = api.Analyze(text, CancellationToken.None); + Assert.NotNull(result.Ast); + foreach (var def in Descendants(result.Ast!).OfType() + .Where(d => master.GetFunction(d.Name) is not null)) + { + var declared = result.SemanticQuery?.GetFunctionDeclarationSymbol(def); + _output.WriteLine($"{name}: def {def.Name} @ {def.LineStart}:{def.ColumnStart} -> " + + (declared is null + ? "" + : $"{declared.Name}({string.Join(", ", declared.Parameters.Select(p => p.Type.GetDisplayName()))}) frozen={declared.IsFrozen}")); + Assert.NotNull(declared); + Assert.False(master.IsBuiltinSymbol(declared!), $"{name}: def {def.Name} resolved to the master builtin"); + Assert.False(declared!.IsFrozen, $"{name}: def {def.Name} resolved to a frozen symbol"); + Assert.Equal(def.LineStart, declared.DeclarationLine); + Assert.Equal(def.Parameters.Length, declared.Parameters.Count); + checkedDefs++; + } + } + + // Positive control on the corpus: both triggers define a builtin's name (len; format) — a filter that matched + // nothing would pass vacuously. + Assert.True(checkedDefs >= 2, $"only {checkedDefs} builtin-named defs were checked"); + } + + /// + /// Design Decision 4 (#2250): the master 's symbols are frozen when its construction + /// completes — a write to one throws instead of retyping the builtin for every later document. Positive and negative + /// controls: every registered type and function overload is frozen (a floor on the count), each kind of write + /// throws, and a with copy — a new declaration its maker owns — is born unfrozen and writable. + /// + [Fact] + public void MasterSymbols_AreFrozen_AndAWriteIsRefused() + { + var api = new CompilerApi(); + var master = api.PrimeAnalysisContextForTests(); + var symbols = new HashSet(); + foreach (var (_, type) in master.GetAllTypes()) + symbols.Add(type); + foreach (var (name, function) in master.GetAllFunctions()) + { + symbols.Add(function); + foreach (var overload in master.GetFunctionOverloads(name) ?? new List()) + symbols.Add(overload); + } + + Assert.True(symbols.Count >= 50, $"only {symbols.Count} master symbols were enumerated"); + var unfrozen = symbols.Where(s => !s.IsFrozen).Select(s => s.ToString()).ToList(); + Assert.True(unfrozen.Count == 0, $"unfrozen master symbols: {string.Join(", ", unfrozen.Take(20))}"); + + var len = master.GetFunction("len")!; + var returnType = len.ReturnType; + var parameter = len.Parameters[0]; + var refused = Assert.Throws(() => len.ReturnType = SemanticType.Str); + _output.WriteLine(refused.Message); + Assert.Throws( + () => len.ReplaceParameter(0, len.Parameters[0] with { Type = SemanticType.Str })); + Assert.Throws(() => len.Documentation = "x"); + var exceptionType = master.GetType("Exception")!; + Assert.Throws(() => exceptionType.BaseType = null); + Assert.Same(returnType, len.ReturnType); + Assert.Same(parameter, len.Parameters[0]); + + var copy = len with { Name = "len_copy", Parameters = new List(len.Parameters) }; + Assert.False(copy.IsFrozen); + copy.ReturnType = SemanticType.Str; + copy.ReplaceParameter(0, copy.Parameters[0] with { Type = SemanticType.Str }); + Assert.Equal(SemanticType.Str, copy.ReturnType); + Assert.Same(parameter, len.Parameters[0]); + } + + /// + /// was a when this scan was written, so no setter could + /// refuse an element write the way the frozen properties do: symbol.Parameters[i] = … on a master symbol + /// retyped the builtin without a throw — the exact #2250 write. Every element write goes through + /// , which checks the freeze, and this scan keeps it that way: no compiler + /// source assigns a Parameters[…] element except Symbol.cs itself. Since the member-collection freeze + /// (, ) the list + /// refuses the write itself too; the scan stays as the source-level layer. + /// + [Fact] + public void NoCompilerSourceWritesAParameterElementPastTheFreeze() + { + // Positive and negative controls for the pattern itself: a direct element write matches, a read and a + // comparison do not. + Assert.Matches(DirectParameterElementWrite, "functionSymbol.Parameters[i] = functionSymbol.Parameters[i] with { Type = t };"); + Assert.DoesNotMatch(DirectParameterElementWrite, "var p = symbol.Parameters[0];"); + Assert.DoesNotMatch(DirectParameterElementWrite, "if (symbol.Parameters[0] == other) { }"); + + var root = Path.Combine(FixtureRoots.RepositoryRoot, "src", "Sharpy.Compiler"); + var files = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .ToList(); + Assert.True(files.Count >= 100, $"the scan saw only {files.Count} compiler sources under {root}"); + + var writes = new List(); + var symbolFileWrites = 0; + foreach (var file in files) + { + var lines = File.ReadAllLines(file); + for (var i = 0; i < lines.Length; i++) + { + if (!DirectParameterElementWrite.IsMatch(lines[i])) + continue; + if (Path.GetFileName(file) == "Symbol.cs") + symbolFileWrites++; + else + writes.Add($"{Path.GetRelativePath(root, file)}:{i + 1}: {lines[i].Trim()}"); + } + } + + // The one sanctioned write (inside ReplaceParameter) is the scan's in-corpus positive control. + Assert.Equal(1, symbolFileWrites); + Assert.True(writes.Count == 0, + "a Parameters element is written directly, past the master-symbol freeze — use FunctionSymbol.ReplaceParameter " + + $"(#2250):\n {string.Join("\n ", writes)}"); + } + + /// + /// The verifier's F1 cell (P54 #2250 @ effba7776) at the compiler level: on ONE , a later + /// document that reads a member the master str does not have + /// () has the diagnostics a fresh instance gives, after EACH + /// trigger text — in particular the type-definition texts, whose class str in a block added a _size + /// backing field to the master str (the probe's SPY0203 vanished). Positive controls: the fresh reading + /// carries SPY0203 (the probe reads what the cell is about), and the same instance answers as the fresh one before + /// any trigger. + /// + [Fact] + public void WarmInstanceTriggers_DoNotChangeALaterDocumentsDiagnostics() + { + var fresh = WarmInstanceTriggers.LaterDocumentProbes.ToDictionary( + p => p.Name, p => DiagnosticFingerprint(new CompilerApi(null, StdlibReferencePaths()), p.Text), StringComparer.Ordinal); + Assert.Contains("SPY0203", fresh["str-has-no-_size"], StringComparison.Ordinal); + + var api = new CompilerApi(null, StdlibReferencePaths()); + var problems = new List(); + void ReadProbes(string after) + { + foreach (var (name, text) in WarmInstanceTriggers.LaterDocumentProbes) + { + var warm = DiagnosticFingerprint(api, text); + _output.WriteLine($"{name} after {after}: {warm}"); + if (!string.Equals(warm, fresh[name], StringComparison.Ordinal)) + problems.Add($"{name} after {after}: warm [{warm}] / fresh [{fresh[name]}]"); + } + } + + ReadProbes("nothing (the positive control)"); + Assert.True(problems.Count == 0, string.Join("\n", problems)); + foreach (var (name, text) in WarmInstanceTriggers.AllTexts) + { + api.Analyze(text, CancellationToken.None); + ReadProbes($"trigger {name}"); + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + /// + /// The verifier's F2 cell and its siblings (P54 #2250 @ effba7776): a type definition in a block or a function body + /// — a class, a @dataclass class, a struct, an interface, a union, a delegate, each named str — is + /// REFUSED (SPY0202: it has no symbol of its own, C# has no local types; plus SPY0340 for a module-level block) and + /// never reaches an internal error: the dataclass text was SPY0909 (PhaseViolationException on the frozen master + /// str) at effba7776. The SPY0202 is the positive control for the absence of SPY09xx — the checker reached the + /// definition. + /// + [Fact] + public void WarmInstanceTriggers_TypeDefinitionsAreRefusedWithoutAnInternalError() + { + var api = new CompilerApi(null, StdlibReferencePaths()); + var problems = new List(); + foreach (var (name, text) in WarmInstanceTriggers.TypeDefinitionTexts) + { + var codes = api.Analyze(text, CancellationToken.None).Diagnostics.Select(d => d.Code ?? "-").ToList(); + _output.WriteLine($"{name}: {string.Join(" ", codes)}"); + if (codes.Any(c => c.StartsWith("SPY09", StringComparison.Ordinal))) + problems.Add($"{name}: an internal error ({string.Join(" ", codes)})"); + if (!codes.Contains("SPY0202")) + problems.Add($"{name}: the type definition was not refused with SPY0202 ({string.Join(" ", codes)})"); + if (text.StartsWith("try:", StringComparison.Ordinal) && !codes.Contains("SPY0340")) + problems.Add($"{name}: the module-level block was not refused with SPY0340 ({string.Join(" ", codes)})"); + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + /// + /// The freeze covers a master symbol's member COLLECTIONS, not only its settable properties (P54 #2250 @ effba7776, + /// F1: typeSymbol.Fields.Add(backingField) wrote the master str through a list the property freeze did + /// not reach). Every / of every master type and + /// function overload is frozen (a floor on the count), and on the master str and len every mutator of + /// every such collection throws and leaves the + /// collection as it was. + /// + [Fact] + public void MasterSymbolCollections_AreFrozen_AndEveryWriteIsRefused() + { + var api = new CompilerApi(); + var master = api.PrimeAnalysisContextForTests(); + var symbols = new List(); + foreach (var (_, type) in master.GetAllTypes()) + symbols.Add(type); + foreach (var (name, _) in master.GetAllFunctions()) + symbols.AddRange(master.GetFunctionOverloads(name) ?? new List()); + + var collections = 0; + var unfrozen = new List(); + foreach (var symbol in symbols) + { + foreach (var (property, collection) in MemberCollections(symbol)) + { + collections++; + if (!IsCollectionFrozen(collection)) + unfrozen.Add($"{symbol.GetType().Name} '{symbol.Name}'.{property}"); + } + } + + Assert.True(collections >= 500, $"only {collections} member collections of master symbols were enumerated"); + Assert.True(unfrozen.Count == 0, $"unfrozen member collections of master symbols: {string.Join(", ", unfrozen.Take(20))}"); + + var refusals = 0; + foreach (var symbol in new Symbol[] { master.GetType("str")!, master.GetFunction("len")! }) + { + foreach (var (property, collection) in MemberCollections(symbol)) + refusals += AssertEveryWriteRefused($"{symbol.GetType().Name} '{symbol.Name}'.{property}", collection); + } + + // TypeSymbol carries 15 member collections and FunctionSymbol 2; each is tried with at least 4 mutators. + Assert.True(refusals >= 4 * 17, $"only {refusals} refused writes were attempted"); + } + + /// + /// The instrument of , both directions, and the + /// totality of FreezeMembers: on a symbol built here, every member collection accepts every mutator BEFORE + /// (positive control — the refusal is the freeze, not the collection type), and refuses + /// every one after it. Enumerated by reflection, so a member collection added to a symbol type and not to its + /// FreezeMembers is red here. A symbol type that declares a raw mutable collection (a , + /// , …) instead of a freezable one is red too: the freeze could not reach it. + /// + [Fact] + public void SymbolMemberCollections_AcceptWritesUntilFrozen_ThenRefuseEvery() + { + var symbols = new Symbol[] + { + new TypeSymbol { Name = "Probe", Kind = SymbolKind.Type }, + new FunctionSymbol { Name = "probe", Kind = SymbolKind.Function }, + }; + + foreach (var symbol in symbols) + { + var members = MemberCollections(symbol).ToList(); + Assert.NotEmpty(members); + foreach (var (property, collection) in members) + { + Assert.False(IsCollectionFrozen(collection), $"{symbol.GetType().Name} '{symbol.Name}'.{property} is frozen before Freeze()"); + AssertEveryWriteAccepted($"{symbol.GetType().Name} '{symbol.Name}'.{property}", collection); + } + + symbol.Freeze(); + foreach (var (property, collection) in members) + { + Assert.True(IsCollectionFrozen(collection), $"{symbol.GetType().Name} '{symbol.Name}'.{property} is not frozen by Freeze() — add it to FreezeMembers"); + AssertEveryWriteRefused($"{symbol.GetType().Name} '{symbol.Name}'.{property}", collection); + } + } + + Assert.Equal(15, MemberCollections(symbols[0]).Count()); + Assert.Equal(2, MemberCollections(symbols[1]).Count()); + + // Every symbol type the master owns declares no collection the freeze cannot reach: the runtime types of the + // master's symbols and their members, plus the member kinds the registry's freeze walk visits (fields and + // dataclass fields are VariableSymbols, nested aliases TypeAliasSymbols) whether or not a builtin has one today. + var api = new CompilerApi(); + var master = api.PrimeAnalysisContextForTests(); + var observed = master.GetAllTypes().Select(p => p.Type) + .SelectMany(t => new Symbol[] { t }.Concat(t.Methods).Concat(t.Fields).Concat(t.Constructors).Concat(t.NestedTypes)) + .Select(s => s.GetType()) + .Distinct() + .ToList(); + Assert.Contains(typeof(TypeSymbol), observed); + Assert.Contains(typeof(FunctionSymbol), observed); + var ownedTypes = observed.Concat(new[] { typeof(VariableSymbol), typeof(TypeAliasSymbol) }).Distinct().ToList(); + var unfreezable = ownedTypes + .SelectMany(t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(p => (Type: t, Property: p))) + .Where(x => IsRawMutableCollection(x.Property.PropertyType)) + .Select(x => $"{x.Type.Name}.{x.Property.Name} : {x.Property.PropertyType.Name}") + .ToList(); + Assert.True(unfreezable.Count == 0, + $"a symbol type the master owns declares a mutable collection the freeze cannot reach — use SymbolMemberList / SymbolOverloadTable (#2250):\n {string.Join("\n ", unfreezable)}"); + Assert.True(IsRawMutableCollection(typeof(List)) && IsRawMutableCollection(typeof(Dictionary)) + && !IsRawMutableCollection(typeof(SymbolMemberList)) && !IsRawMutableCollection(typeof(IReadOnlyList)), + "the raw-mutable-collection classifier is wrong"); + } + + /// A symbol's freezable member collections, by property name (reflection — the totality is the point). + private static IEnumerable<(string Property, object Collection)> MemberCollections(Symbol symbol) + { + foreach (var property in symbol.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var type = property.PropertyType; + var freezable = type == typeof(SymbolOverloadTable) + || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(SymbolMemberList<>)); + if (freezable && property.GetValue(symbol) is { } value) + yield return (property.Name, value); + } + } + + private static bool IsCollectionFrozen(object collection) + => (bool)collection.GetType().GetProperty("IsFrozen", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(collection)!; + + private static bool IsRawMutableCollection(Type type) + { + if (!type.IsGenericType) + return false; + var definition = type.GetGenericTypeDefinition(); + return definition == typeof(List<>) || definition == typeof(Dictionary<,>) || definition == typeof(HashSet<>) + || definition == typeof(IList<>) || definition == typeof(ICollection<>) || definition == typeof(IDictionary<,>) + || definition == typeof(ISet<>); + } + + /// Each mutator of succeeds, and the collection ends empty again. + private static void AssertEveryWriteAccepted(string what, object collection) + { + if (collection is SymbolOverloadTable table) + { + table["__probe__"] = new SymbolMemberList(); + table["__probe__"].Add(new FunctionSymbol { Name = "__probe__", Kind = SymbolKind.Function }); + Assert.True(table.Remove("__probe__"), what); + Assert.True(table.TryAdd("__probe__", new SymbolMemberList()), what); + table.Remove("__probe__"); + table.Add("__probe__", new SymbolMemberList()); + table.Remove("__probe__"); + Assert.Empty(table); + return; + } + + var list = (IList)collection; + var sample = SampleElement(collection); + list.Add(sample); + list[0] = sample; + list.Insert(0, sample); + list.Remove(sample); + list.RemoveAt(0); + Assert.Empty(list); + list.Add(sample); + list.Clear(); + Assert.Empty(list); + } + + /// Each mutator of throws and leaves it unchanged; returns the count tried. + private static int AssertEveryWriteRefused(string what, object collection) + { + static void Refused(string what, string mutator, Action write) + { + var thrown = Record.Exception(write); + Assert.True(thrown is Sharpy.Compiler.Diagnostics.PhaseViolationException, + $"{what}: {mutator} on a frozen collection was {(thrown is null ? "accepted" : $"a {thrown.GetType().Name}")}"); + } + + if (collection is SymbolOverloadTable table) + { + var keys = table.Keys.ToList(); + var triedTable = 4; + Refused(what, "indexer set", () => table["__probe__"] = new SymbolMemberList()); + Refused(what, "TryAdd", () => table.TryAdd("__probe__", new SymbolMemberList())); + Refused(what, "Add", () => table.Add("__probe__", new SymbolMemberList())); + Refused(what, "Remove", () => table.Remove(keys.FirstOrDefault() ?? "__probe__")); + foreach (var key in keys) + { + triedTable += AssertEveryWriteRefused($"{what}[{key}]", table[key]); + } + + Assert.Equal(keys, table.Keys.ToList()); + return triedTable; + } + + var list = (IList)collection; + var before = list.Cast().ToList(); + var sample = SampleElement(collection); + Refused(what, "Add", () => list.Add(sample)); + Refused(what, "Insert", () => list.Insert(0, sample)); + Refused(what, "Clear", list.Clear); + Refused(what, "AddRange", () => + { + try + { + collection.GetType().GetMethod("AddRange")!.Invoke( + collection, new object[] { Array.CreateInstance(collection.GetType().GetGenericArguments()[0], 1) }); + } + catch (TargetInvocationException invocation) when (invocation.InnerException is { } inner) + { + throw inner; + } + }); + var tried = 4; + + // An empty list has no element to replace or remove — those mutators are bounds/lookup no-ops before any write. + if (before.Count > 0) + { + Refused(what, "indexer set", () => list[0] = before[0]); + Refused(what, "Remove", () => list.Remove(before[0])); + Refused(what, "RemoveAt", () => list.RemoveAt(0)); + tried += 3; + } + + Assert.Equal(before, list.Cast().ToList()); + return tried; + } + + /// + /// An element of the right type for : its first element, else null (every member + /// collection holds a reference type; a value type gets its default). + /// + private static object? SampleElement(object collection) + { + var list = (IList)collection; + if (list.Count > 0) + return list[0]; + var elementType = collection.GetType().GetGenericArguments()[0]; + return elementType.IsValueType ? Activator.CreateInstance(elementType) : null; + } + + private static string DiagnosticFingerprint(CompilerApi api, string text) + => string.Join(" ", api.Analyze(text, CancellationToken.None).Diagnostics + .Select(d => $"{d.Code ?? "-"}@{d.Line}:{d.Column}") + .OrderBy(s => s, StringComparer.Ordinal)); + + private static readonly System.Text.RegularExpressions.Regex DirectParameterElementWrite = + new(@"\bParameters\[[^\]]+\]\s*=(?!=)", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static IEnumerable Descendants(SAst.Node root) + { + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + var node = stack.Pop(); + yield return node; + foreach (var child in node.GetChildNodes()) + { + if (child is not null) + stack.Push(child); + } + } + } + + /// The effective type of each named builtin identifier in , analysed on . + private static Dictionary BuiltinIdentifierTypes(CompilerApi api, string source, string[] names) + { + var result = api.Analyze(source, CancellationToken.None); + var types = new Dictionary(StringComparer.Ordinal); + var stack = new Stack(); + stack.Push(result.Ast ?? throw new InvalidOperationException("the probe did not parse")); + while (stack.Count > 0) + { + var node = stack.Pop(); + if (node is SAst.Identifier id && names.Contains(id.Name)) + types[id.Name] = result.SemanticQuery?.GetEffectiveType(id)?.GetDisplayName() ?? ""; + foreach (var child in node.GetChildNodes()) + { + if (child is not null) + stack.Push(child); + } + } + + return types; + } + + /// A CLR interface no builtin implements: the positive control's key. + private interface IKeySetProbe + { + } + + private static void AssertSameMaster(CompilerApi api, BuiltinRegistry master, string what) + => Assert.True(ReferenceEquals(master, api.CachedBuiltinsForTests), + $"{what} replaced the cached master registry; the key sets would describe a registry nobody uses"); + + /// The Core and Stdlib assemblies beside the test assembly — the server's default reference pair. + private static string[] StdlibReferencePaths() + { + var paths = new[] { "Sharpy.Core.dll", "Sharpy.Stdlib.dll" }.Select(n => Path.Combine(AppContext.BaseDirectory, n)).ToArray(); + foreach (var path in paths) + Assert.True(File.Exists(path), $"{path} is missing beside the test assembly"); + return paths; + } + + /// + /// (name, source, applied) for each P47 stem twin, each program (its applied + /// text is the Wrap-in-try text measured to move the P47 stems on one instance) and each corpus program. A program + /// with no applied text re-analyses its edited source (one trailing line break). Extract method's own applied text + /// for the P47 stems is WarmInstanceParityTests' (Lsp.Tests) step — it needs the LSP handler. + /// + private static List<(string Name, string Source, string? Applied)> RequestSequencePrograms() + { + var programs = new List<(string, string, string?)>(); + foreach (var stem in P47Stems) + { + var source = File.ReadAllText(Path.Combine(FixtureRoots.CompilerTests.Path, stem + ".spy")); + programs.Add((stem, source, null)); + programs.Add((stem + "#cr", FormatterTwins.Cr(source), null)); + } + + foreach (var (name, fixture, applied) in WarmInstanceTriggers.AppliedTexts) + programs.Add((name, File.ReadAllText(Path.Combine(FixtureRoots.CompilerTests.Path, fixture + ".spy")), applied)); + foreach (var (name, text) in WarmInstanceTriggers.TypeDefinitionTexts) + programs.Add((name, text, null)); + foreach (var (name, source) in Corpus) + programs.Add((name, source, null)); + return programs; + } + + private static long s_documentId; + + /// + /// One pass of the request sequence: per program, the LSP-route analysis, the handler queries on it, the applied + /// text's analysis and the oracle's path-less source analysis — each a labelled step. + /// + private static IEnumerable<(string Label, Action Step)> RequestSequence(CompilerApi api, List<(string Name, string Source, string? Applied)> programs, int pass) + { + foreach (var (name, source, applied) in programs) + { + SemanticResult? analysis = null; + yield return ($"pass {pass} {name} analyze", () => analysis = api.AnalyzeDocument( + source, $"/master-registry-keysets/doc{Interlocked.Increment(ref s_documentId)}.spy", + CompilerOptionsFactory.ForLsp(), null, CancellationToken.None)); + yield return ($"pass {pass} {name} handler queries", () => RunHandlerQueries(analysis!)); + yield return ($"pass {pass} {name} applied-text analyze", () => api.Analyze(applied ?? source + "\n", CancellationToken.None)); + yield return ($"pass {pass} {name} oracle analyze", () => api.Analyze(source, CancellationToken.None)); + } + } + + /// + /// The semantic queries the LSP handlers make on an analysis (hover, Extract method's parameter typing, find + /// references, signature help): every identifier's expression/effective type, symbol and references, every + /// call's target, routing and inferred type arguments, every annotation's type, every declaration's symbol — and + /// each type's display name, which is what Extract method spells. + /// + private static void RunHandlerQueries(SemanticResult analysis) + { + var query = analysis.SemanticQuery; + if (analysis.Ast is null || query is null) + return; + var stack = new Stack(); + stack.Push(analysis.Ast); + while (stack.Count > 0) + { + var node = stack.Pop(); + switch (node) + { + case SAst.Identifier id: + _ = query.GetExpressionType(id)?.GetDisplayName(); + _ = query.GetEffectiveType(id)?.GetDisplayName(); + if (query.GetIdentifierSymbol(id) is { } symbol) + _ = query.GetReferences(symbol).Count; + break; + case SAst.FunctionCall call: + _ = query.GetCallTarget(call)?.Name; + _ = query.GetCalleeRouting(call); + _ = query.GetInferredTypeArguments(call)?.Count; + _ = query.GetExpressionType(call)?.GetDisplayName(); + break; + case SAst.TypeAnnotation annotation: + _ = query.GetTypeAnnotation(annotation)?.GetDisplayName(); + break; + case SAst.VariableDeclaration declaration: + _ = query.GetDeclarationSymbol(declaration)?.Name; + break; + case SAst.FunctionDef definition: + _ = query.GetFunctionDeclarationSymbol(definition)?.Name; + break; + case SAst.Expression expression: + _ = query.GetEffectiveType(expression)?.GetDisplayName(); + break; + } + + foreach (var child in node.GetChildNodes()) + { + if (child is not null) + stack.Push(child); + } + } + } + + #endregion + #region Comparison plumbing private void AssertUnchanged(CompilerApi api, BuiltinRegistry master, RegistrySnapshot snapshot, string entryPoint) @@ -451,9 +1266,16 @@ private int IdOf(Symbol symbol, string path) { if (node is IEnumerable enumerable and not string) { + // A dictionary VALUE may be null (the master's `_clrTypeCache` caches a name that resolved to no + // type as null, #2250's key-set walk) — nothing to walk into, and Unwrap needs an object. foreach (var element in Enumerate(enumerable)) + { + if (element is null) + continue; foreach (var child in Unwrap(element)) yield return (child, $"{path}[]"); + } + yield break; } diff --git a/src/Sharpy.Compiler.Tests/Project/ModuleNamespaceShadowsClrTypeTests.cs b/src/Sharpy.Compiler.Tests/Project/ModuleNamespaceShadowsClrTypeTests.cs index f5f00daf1..7b3cb8da2 100644 --- a/src/Sharpy.Compiler.Tests/Project/ModuleNamespaceShadowsClrTypeTests.cs +++ b/src/Sharpy.Compiler.Tests/Project/ModuleNamespaceShadowsClrTypeTests.cs @@ -188,6 +188,12 @@ public void ModuleNamespaceShadowingATypeAnyFileUses_IsRefusedByName( var report = string.Join("\n", diagnostics.Where(d => d.IsError).Select(d => $"{d.Code} {d.FilePath}:{d.Line}:{d.Column} {d.Message}")); diagnostics.Should().NotContain(d => d.Code == DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError, $"[{route}/{cell}] never CS0118/CS0234 behind SPY0908\n{report}"); + // Every cell builds its own AppTypes.dll in a fresh directory. Before each registry owned its + // load context (#2250, R-HI) the second and later same-named loads in this process recorded + // SPY0306 (filtered out) and then compiled against the FIRST copy; now each compile loads the + // file it was given. + diagnostics.Should().NotContain(d => d.Code == DiagnosticCodes.Semantic.AssemblyLoadError, + $"[{route}/{cell}] the reference loads as given — no SPY0306\n{report}"); if (shadowed != null) { success.Should().BeFalse($"[{route}/{cell}] is refused"); diff --git a/src/Sharpy.Compiler.Tests/Project/RegistryErrorDoorTests.cs b/src/Sharpy.Compiler.Tests/Project/RegistryErrorDoorTests.cs index 5a6ba1973..c0041c70b 100644 --- a/src/Sharpy.Compiler.Tests/Project/RegistryErrorDoorTests.cs +++ b/src/Sharpy.Compiler.Tests/Project/RegistryErrorDoorTests.cs @@ -13,7 +13,7 @@ namespace Sharpy.Compiler.Tests.Project; /// Every public compiler entry point that builds a module registry from a /// reports a reference that resolves to no file as SPY0305 (#2223, /// plan-05dd79 Decision 5), through the one registry read -/// ModuleRegistry.MissingReferenceDiagnostics. +/// ModuleRegistry.ReferenceDiagnostics. /// /// /// The doors are enumerated by reflection and pinned to a LITERAL list of three, so a new @@ -114,6 +114,57 @@ public void Door_ReportsAMissingReferenceAsSpy0305(string door, bool missingRefe } } + /// + /// R-HI (#2250, #2182): EVERY registry error reaches every door, not only SPY0305. A reference + /// file that exists but is not a loadable assembly is SPY0306 ("cannot be loaded as given" — its + /// one meaning since each registry owns its load context); before, the doors read SPY0305 only, so + /// the compile went on and the Roslyn step met the bad file. The control is the same project with + /// a real assembly at that path: no SPY0306, success. + /// + [Theory] + [InlineData(CompileProject, true)] + [InlineData(CompileProject, false)] + [InlineData(CompileProjectWithToken, true)] + [InlineData(CompileProjectWithToken, false)] + [InlineData(AnalyzeProject, true)] + [InlineData(AnalyzeProject, false)] + public void Door_ReportsAnUnloadableReferenceAsSpy0306(string door, bool unloadable) + { + var dir = WriteProject("libs/Bad.dll"); + var badPath = Path.Combine(dir, "libs", "Bad.dll"); + if (unloadable) + { + Directory.CreateDirectory(Path.GetDirectoryName(badPath)!); + File.WriteAllText(badPath, "this is not a .NET assembly"); + } + else + { + EmitTinyAssembly(badPath); + } + var config = ProjectFileParser.Load(Path.Combine(dir, "app.spyproj")); + var cell = $"[{door}, unloadable={unloadable}]"; + + var (success, diagnostics, outputPath) = Run(door, config); + var spy0306 = diagnostics.Where(d => d.Code == DiagnosticCodes.Semantic.AssemblyLoadError).ToList(); + var report = string.Join("\n", diagnostics.Where(d => d.IsError).Select(d => $"{d.Code}: {d.Message}")); + + if (unloadable) + { + success.Should().BeFalse($"{cell} a reference that cannot be loaded fails the door\n{report}"); + spy0306.Should().ContainSingle($"{cell} one SPY0306 for the one unloadable reference\n{report}"); + spy0306[0].Message.Should().Contain(badPath, $"{cell} the diagnostic names the path"); + diagnostics.Should().NotContain(d => d.Code == DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError, + $"{cell} refused by the registry, never by the C# compiler behind SPY0908\n{report}"); + if (door != AnalyzeProject) + outputPath.Should().BeNull($"{cell} nothing was compiled"); + } + else + { + spy0306.Should().BeEmpty($"{cell} control: a real assembly loads\n{report}"); + success.Should().BeTrue($"{cell} control compiles\n{report}"); + } + } + /// /// The analysis door reads a CACHED registry (keyed by reference path + mtime): a warm call must /// still report SPY0305, the reference appearing must clear it, and the reference vanishing diff --git a/src/Sharpy.Compiler.Tests/Project/RegistryIsolationTests.cs b/src/Sharpy.Compiler.Tests/Project/RegistryIsolationTests.cs new file mode 100644 index 000000000..9083c1255 --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Project/RegistryIsolationTests.cs @@ -0,0 +1,348 @@ +extern alias SharpyRT; + +using System.Runtime.Loader; +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Sharpy.Compiler.Diagnostics; +using Sharpy.Compiler.Semantic.Registry; +using Sharpy.TestInfrastructure.Integration; +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Compiler.Tests.Project; + +/// +/// P54 (#2250) cell 5, the in-process twin of the compile-server request sequence: a compile is a +/// pure function of its inputs, so two compiles in ONE process that reference two different files +/// with the SAME assembly simple name each see the file they were given. Two +/// P54IsoTypes.dll are built into two temp directories — copy A declares Isoapp.Alpha, +/// copy B declares Isoapp.Beta — and two instances ("registry A", +/// "registry B") compile against them in a fixed order. +/// +/// +/// The simple name P54IsoTypes is used by no other test, so nothing else in the test host +/// loads it first and the run order below is the whole history of the default load context for that +/// name: A, then B. Cells (in run order): +/// +/// A/Alpha — registry A imports its own member: compiles (positive control). +/// B/Beta — registry B imports its own member: must compile against B's file. +/// A/Beta — registry A, after B loaded its copy, imports B's member: refused by a +/// diagnostic (a reference another registry loaded is invisible; the plan's inert-fix control). +/// B/Alpha — registry B imports A's member: refused by a diagnostic (B never sees A's +/// copy; the "compiled against the first copy" discriminator). +/// +/// A cell red at base is a literal row holding the measured base fingerprint: +/// the test asserts the defect still reproduces, so the Phase 2 cure fails the row as stale. +/// +[Collection("LoadContextRetention")] +public class RegistryIsolationTests : IDisposable +{ + private const string SharedSimpleName = "P54IsoTypes"; + + private readonly ITestOutputHelper _output; + private readonly string _dir = Path.Combine(Path.GetTempPath(), $"sharpy_p54iso_{Guid.NewGuid():N}"); + + /// + /// Deterministic base reproductions, keyed by cell id; the value is the observed fingerprint at + /// base (see ). Empty since P54 Phase 2 task 2 (#2250): each registry + /// loads its references into its own load context, so B/Beta (was "refused | SPY0200,SPY0301" — + /// B's second P54IsoTypes.dll could not enter the default context and B saw A's copy) and + /// B/Alpha (was "refused | SPY0908(CS0234)" — B resolved A's Alpha from A's copy) reported stale + /// and drained. + /// + private static readonly IReadOnlyDictionary KnownRed = new Dictionary + { + }; + + public RegistryIsolationTests(ITestOutputHelper output) + { + _output = output; + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try + { Directory.Delete(_dir, recursive: true); } + catch { /* best effort */ } + } + + private string BuildCopy(string label, string className, int value) + { + var directory = Path.Combine(_dir, label); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, SharedSimpleName + ".dll"); + var compilation = CSharpCompilation.Create( + SharedSimpleName, + new[] { CSharpSyntaxTree.ParseText( + $"namespace Isoapp {{ public class {className} {{ public static int Value() => {value}; }} }}") }, + IntegrationTestBase.GetSharedReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var emit = compilation.Emit(path); + emit.Success.Should().BeTrue(string.Join("\n", emit.Diagnostics)); + return path; + } + + [Fact] + public void TwoRegistries_SameNamedAssemblyFromTwoPaths_EachSeesItsOwn() + { + var dlls = new Dictionary + { + ["A"] = BuildCopy("a", "Alpha", 1), + ["B"] = BuildCopy("b", "Beta", 2), + }; + var registries = new Dictionary + { + ["A"] = new CompilerApi(), + ["B"] = new CompilerApi(), + }; + + var cells = new (string Id, string Registry, string Import, bool Visible)[] + { + ("A/Alpha", "A", "Alpha", true), + ("B/Beta", "B", "Beta", true), + ("A/Beta", "A", "Beta", false), + ("B/Alpha", "B", "Alpha", false), + }; + + var failures = new List(); + foreach (var cell in cells) + { + var own = dlls[cell.Registry]; + var dir = Path.Combine(_dir, "run_" + cell.Id.Replace('/', '_')); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "main.spy"); + var source = $"from isoapp import {cell.Import}\n\ndef main() -> None:\n print({cell.Import}.value())\n"; + File.WriteAllText(mainPath, source); + + var result = registries[cell.Registry].Compile(source, + CompilerOptionsFactory.ForCli(references: new[] { own }, + assemblyName: "IsoRun_" + cell.Id.Replace('/', '_'), + outputAssemblyPath: Path.Combine(dir, "out", "main.dll")), + mainPath); + + var fingerprint = Fingerprint(result); + var report = string.Join("\n", result.Diagnostics.Select(d => $" {d.Code} {d.Line}:{d.Column} {d.Message}")); + _output.WriteLine($"[{cell.Id}] {fingerprint}\n{report}"); + + if (KnownRed.TryGetValue(cell.Id, out var recorded)) + { + if (fingerprint != recorded) + failures.Add($"[{cell.Id}] KnownRed row is stale — drain it (#2250): recorded '{recorded}', observed '{fingerprint}'\n{report}"); + continue; + } + + var errors = result.Diagnostics.Where(d => d.IsError).ToList(); + if (cell.Visible) + { + // The Roslyn step compiles against THIS registry's file only, so success means the + // member resolved from that file. (UsedAssemblyPaths is not read: the CLR-namespace + // import arms do not record it — #2313's cell, not this one.) + if (!result.Success || errors.Count > 0) + failures.Add($"[{cell.Id}] must compile against its own reference; observed '{fingerprint}'\n{report}"); + } + else if (result.Success + || errors.Count == 0 + || errors.Any(d => d.Code == DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError)) + { + failures.Add($"[{cell.Id}] must be refused by a diagnostic (the member is in the OTHER registry's copy); observed '{fingerprint}'\n{report}"); + } + } + + failures.Should().BeEmpty(string.Join("\n\n", failures)); + } + + /// + /// ok|refused | sorted distinct diagnostic codes, a SPY0908 spelled with the C# error it + /// wraps — a literal string so a KnownRed row holds the base observation verbatim. + /// + private static string Fingerprint(CompileResult result) + { + var codes = result.Diagnostics + .Select(d => d.Code == DiagnosticCodes.Infrastructure.GeneratedCodeCompilationError + ? $"{d.Code}({Regex.Match(d.Message, @"\bCS\d{4}\b").Value})" + : d.Code) + .Distinct() + .OrderBy(c => c, StringComparer.Ordinal); + var joined = string.Join(",", codes); + return $"{(result.Success ? "ok" : "refused")} | {(joined.Length == 0 ? "-" : joined)}"; + } + + /// + /// One identity for Sharpy.Core (P54 Design Decision 2): a referenced library loads into the + /// registry's own collectible context, but the Sharpy.Core it was built against is the compiler's + /// own copy — Load(AssemblyName) defers to the default context for a name the registry did + /// not load — so a Sharpy.List<int> in the library's signature IS the compiler's + /// typeof(Sharpy.List<>). Positive control: the library itself is NOT the default + /// context's (it is the registry's own copy). + /// + [Fact] + public void ReferencedLibrary_SharesTheCompilersSharpyCore() + { + var path = Path.Combine(_dir, "corelib", "P54CoreIdentity.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var compilation = CSharpCompilation.Create( + "P54CoreIdentity", + new[] { CSharpSyntaxTree.ParseText( + "namespace CoreIdentity { public static class Maker { public static Sharpy.List Make() => new Sharpy.List(); } }") }, + IntegrationTestBase.GetSharedReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var emit = compilation.Emit(path); + emit.Success.Should().BeTrue(string.Join("\n", emit.Diagnostics)); + + var registry = new ModuleRegistry(); + registry.LoadReference(path).Should().BeTrue(string.Join("\n", registry.Diagnostics.GetAll())); + registry.LoadReference(typeof(SharpyRT::Sharpy.Builtins).Assembly.Location).Should().BeTrue(); + + var maker = registry.Visibility.ResolveType("CoreIdentity.Maker"); + maker.Should().NotBeNull("the registry sees its own reference"); + AssemblyLoadContext.GetLoadContext(maker!.Assembly).Should().NotBeSameAs(AssemblyLoadContext.Default, + "positive control: the library is the registry's own copy, not the process's"); + maker.Assembly.IsCollectible.Should().BeTrue("the registry's context is collectible"); + + var returned = maker.GetMethod("Make")!.ReturnType; + returned.GetGenericTypeDefinition().Should().BeSameAs(typeof(SharpyRT::Sharpy.List<>), + "the library's Sharpy.Core is the compiler's — one identity"); + registry.LoadedAssemblies.Single(a => a.GetName().Name == "Sharpy.Core") + .Should().BeSameAs(typeof(SharpyRT::Sharpy.Builtins).Assembly, + "a Sharpy.Core reference is the framework copy, never a second load"); + registry.ReferenceDiagnostics().Should().BeEmpty(); + } + + /// + /// A compile server (or any long-lived host) must not retain one load context per request (P54 + /// Design Decision 3): after 50 compiles in one process, each with its own reference, and a GC, + /// the contexts holding that reference are bounded. The process-wide Type-keyed caches cache only + /// process-stable types, so nothing pins a request's context once its registry is unreachable. + /// In-process twin of the compile server (one CompilerApi per request, as the server's + /// per-request compile builds a fresh registry). + /// + [Fact] + public void CompileServer_FiftyRequests_LoadContextsCollect() + { + const int Requests = 50; + const int Ceiling = 2; + // Instance members and a property, so each compile reaches the process-wide Type-keyed caches + // (CLR method / property name resolution, member-name surfaces) with the reference's types. + const string simpleName = "P54Retention"; + var dll = Path.Combine(_dir, "retention", simpleName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(dll)!); + var library = CSharpCompilation.Create( + simpleName, + new[] { CSharpSyntaxTree.ParseText( + "namespace Retainapp { public class Counter { public int GetValue() => 1; " + + "public int Count => 2; public static Counter Make() => new Counter(); } }") }, + IntegrationTestBase.GetSharedReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var emitted = library.Emit(dll); + emitted.Success.Should().BeTrue(string.Join("\n", emitted.Diagnostics)); + + // 50 DISTINCT reference paths: each request's reference set is new, so each gets its own + // context and its own share entry — the shape a server sees when a library is rebuilt. + var copies = Enumerable.Range(0, Requests + 1).Select(i => + { + var copy = Path.Combine(_dir, "retention", "copy" + i.ToString(System.Globalization.CultureInfo.InvariantCulture), simpleName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(copy)!); + File.Copy(dll, copy); + return copy; + }).ToList(); + + for (var i = 0; i < Requests; i++) + CompileOnce(copies[i], i); + + // Deterministic half: no process-wide static cache in the compiler holds a key from a + // request's reference (a Type, an Assembly, or a tuple carrying one) — such a key would pin + // that request's context for the life of the process. + var pinned = StaticCacheKeysFrom(simpleName); + _output.WriteLine($"static cache keys from {simpleName}: {pinned.Count} [{string.Join(", ", pinned.Take(5))}]"); + pinned.Should().BeEmpty("a static Type-keyed cache must not hold a per-request type (#2250 Design Decision 3)"); + + var alive = int.MaxValue; + for (var attempt = 0; attempt < 20 && alive > Ceiling; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + alive = AssemblyLoadContext.All.Count(c => c != AssemblyLoadContext.Default + && c.Assemblies.Any(a => a.GetName().Name == simpleName)); + } + + _output.WriteLine($"{alive} of {Requests} request load contexts still alive after GC (ceiling {Ceiling})"); + alive.Should().BeLessThanOrEqualTo(Ceiling, + "a request's load context must be collectible once its compile is done (#2250 Design Decision 3)"); + + // The process-wide share must not grow with dead entries: one more request (a new reference + // set) replaces / prunes every dead entry, so the share holds only what is alive. + var entriesBefore = ReferenceLoadContextShareCount(); + CompileOnce(copies[Requests], Requests); + var entriesAfter = ReferenceLoadContextShareCount(); + _output.WriteLine($"share entries: {entriesBefore} after {Requests} requests + GC, {entriesAfter} after one more request (ceiling {ShareCeiling})"); + entriesAfter.Should().BeLessThanOrEqualTo(ShareCeiling, + "dead share entries are pruned when a new context is created (#2250)"); + } + + /// This probe's share entries after a prune: the one new context plus at most one survivor. + private const int ShareCeiling = 2; + + private static int ReferenceLoadContextShareCount() + => Sharpy.Compiler.Discovery.ReferenceLoadContext.SharedKeys.Count(k => k.Contains("P54Retention", StringComparison.Ordinal)); + + /// + /// The keys of every static ConcurrentDictionary in the compiler assembly that are (or + /// carry, in a value tuple) a or from the + /// assembly named . + /// + private static List StaticCacheKeysFrom(string simpleName) + { + bool FromIt(object? o) => o switch + { + Type t => t.Assembly.GetName().Name == simpleName + || (t.IsConstructedGenericType && t.GetGenericArguments().Any(FromIt)), + System.Reflection.Assembly a => a.GetName().Name == simpleName, + System.Runtime.CompilerServices.ITuple tuple => Enumerable.Range(0, tuple.Length).Any(i => FromIt(tuple[i])), + _ => false, + }; + + var found = new List(); + var flags = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.DeclaredOnly; + foreach (var type in typeof(CompilerApi).Assembly.GetTypes()) + { + if (type.IsGenericTypeDefinition) + continue; + foreach (var field in type.GetFields(flags)) + { + if (!field.FieldType.IsGenericType + || field.FieldType.GetGenericTypeDefinition() != typeof(System.Collections.Concurrent.ConcurrentDictionary<,>)) + continue; + if (field.GetValue(null) is not System.Collections.IDictionary cache) + continue; + foreach (var key in cache.Keys) + { + if (FromIt(key)) + found.Add($"{type.Name}.{field.Name}"); + } + } + } + return found; + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private void CompileOnce(string dll, int i) + { + var dir = Path.Combine(_dir, "retention_run", i.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "main.spy"); + var source = "from retainapp import Counter\n\ndef main() -> None:\n c = Counter.make()\n" + + " print(c.get_value())\n print(c.count)\n"; + File.WriteAllText(mainPath, source); + var result = new CompilerApi().Compile(source, + CompilerOptionsFactory.ForCli(references: new[] { dll }, + assemblyName: "Retention" + i.ToString(System.Globalization.CultureInfo.InvariantCulture), + outputAssemblyPath: Path.Combine(dir, "out", "main.dll")), + mainPath); + result.Success.Should().BeTrue(string.Join("\n", result.Diagnostics.Select(d => $"{d.Code} {d.Message}"))); + } +} diff --git a/src/Sharpy.Compiler.Tests/Semantic/ProtocolMembershipTests.cs b/src/Sharpy.Compiler.Tests/Semantic/ProtocolMembershipTests.cs index 110fa9d87..68a2f6284 100644 --- a/src/Sharpy.Compiler.Tests/Semantic/ProtocolMembershipTests.cs +++ b/src/Sharpy.Compiler.Tests/Semantic/ProtocolMembershipTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using FluentAssertions; +using Sharpy.Compiler.Discovery; using Sharpy.Compiler.Semantic; using Sharpy.Compiler.Semantic.Registry; using Sharpy.Compiler.Shared; @@ -47,7 +48,7 @@ public void RegistryTable_NeverDeniesWhatTheClrShapeProves() { var builtins = new BuiltinRegistry(); var symbolTable = new SymbolTable(builtins); - var membership = new ProtocolMembership(symbolTable, builtins); + var membership = new ProtocolMembership(symbolTable, builtins, AssemblyVisibility.FrameworkOnly); var delta = new HashSet<(string TypeName, string Dunder)>(); var genericTypesChecked = 0; diff --git a/src/Sharpy.Compiler.Tests/Semantic/TypeDefinitionOwnSymbolMatrixTests.cs b/src/Sharpy.Compiler.Tests/Semantic/TypeDefinitionOwnSymbolMatrixTests.cs new file mode 100644 index 000000000..c8460fe41 --- /dev/null +++ b/src/Sharpy.Compiler.Tests/Semantic/TypeDefinitionOwnSymbolMatrixTests.cs @@ -0,0 +1,194 @@ +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Compiler.Tests.Semantic; + +/// +/// P54 (#2250) final verification F1/F2 @ effba7776, as a class: a type definition writes only the symbol declared +/// for it. NameResolver declares the type definitions of a module body and of a type body; one anywhere else — a +/// function body, or a block — has no symbol (C# has no local types). The checker used to look the NAME up and write +/// whatever it found: the master builtin str (a _size backing field; IsDataclass, which the +/// master freeze turned into a SPY0909 ICE) or an enclosing user type (Foo()._size lost its SPY0203 within ONE +/// compile). Now it is refused (SPY0202) and writes nothing. +/// +/// Matrix (generated): definition kind (7: class, @dataclass class, struct, interface, union, delegate — +/// the class and struct carry the mixed auto/setter property whose backing field was the measured write — and a class +/// with a non-constant const X, whose body ConstEligibility walked with the enclosing Foo it found +/// by name, recording "not a compile-time constant" on Foo.X) × position (4: +/// function body, if in a function, for in a function, module-level try) × name (3: the builtin +/// str; Foo, a module-level user class; Fresh, bound to nothing — the column that was a SPY0202 +/// refusal at the base too, the positive control). Each cell is analysed on ONE beside its +/// CONTROL — the same program with the definition's lines replaced by pass lines, so every line keeps its number. +/// +/// Oracle per cell. (1) No internal error (SPY09xx). (2) The definition is refused with SPY0202 at its +/// line (the positive control for (1): the checker reached it). (3) Every diagnostic off the definition's line (which also carries the SPY0453 shadowing warning for str) equals the control's — +/// main reads s._size / f._size, so a written str or Foo changes it. (4) The +/// module-level Foo's members — names, and each field's folded constant and compile-time fact — equal the +/// control's. +/// +public class TypeDefinitionOwnSymbolMatrixTests +{ + private readonly ITestOutputHelper _output; + + public TypeDefinitionOwnSymbolMatrixTests(ITestOutputHelper output) + { + _output = output; + } + + /// (kind, the definition's lines for a name, unindented). + private static readonly (string Kind, Func Lines)[] Kinds = + { + ("class", n => new[] + { + $"class {n}:", + " property size: int = 0", + " property set size(self, v: int) -> None:", + " self._size = v", + }), + ("dataclass", n => new[] + { + "@dataclass", + $"class {n}:", + " x: int", + }), + ("struct", n => new[] + { + $"struct {n}:", + " property size: int = 0", + " property set size(self, v: int) -> None:", + " self._size = v", + }), + ("interface", n => new[] + { + $"interface {n}:", + " def probe(self, value: int) -> int: ...", + }), + ("union", n => new[] + { + $"union {n}:", + " case Probe(value: int)", + }), + ("delegate", n => new[] + { + $"delegate {n}(value: int) -> int", + }), + ("class-const", n => new[] + { + $"class {n}:", + " const X: int = len(\"abc\")", + }), + }; + + /// (position, the lines before the definition, its indentation, the lines after it). + private static readonly (string Position, string[] Before, int Indent, string[] After)[] Positions = + { + ("def-body", new[] { "def helper() -> None:" }, 4, new[] { " print(\"helper\")" }), + ("if-in-def", new[] { "def helper() -> None:", " if True:" }, 8, new[] { " print(\"helper\")" }), + ("for-in-def", new[] { "def helper() -> None:", " for i in range(1):" }, 8, new[] { " print(\"helper\")" }), + ("module-try", new[] { "try:" }, 4, new[] { "except Exception:", " pass" }), + }; + + /// (name, the module-level lines before the definition, main's body). + private static readonly (string Name, string[] Prelude, string[] MainBody)[] Names = + { + ("str", Array.Empty(), new[] { " s = \"abc\"", " print(s._size)" }), + ("Foo", new[] { "class Foo:", " const X: int = 5", "", " def __init__(self) -> None:", " pass", "" }, new[] { " f: Foo = Foo()", " print(f._size)" }), + ("Fresh", Array.Empty(), new[] { " print(\"ok\")" }), + }; + + internal sealed record Cell(string Kind, string Position, string Name, string Source, string Control, int DefinitionLine) + { + public string Id => $"{Kind} × {Position} × {Name}"; + } + + internal static IEnumerable Cells() + { + foreach (var (kind, lines) in Kinds) + { + foreach (var (position, before, indent, after) in Positions) + { + foreach (var (name, prelude, mainBody) in Names) + { + var definition = lines(name).Select(l => new string(' ', indent) + l).ToArray(); + var placeholder = definition.Select(_ => new string(' ', indent) + "pass").ToArray(); + string Program(string[] body) => string.Join("\n", + prelude.Concat(before).Concat(body).Concat(after) + .Concat(new[] { "", "def main() -> None:" }).Concat(mainBody).Append("")); + + // 1-based line of the definition's `class`/`struct`/… keyword: a decorator line comes first. + var keywordOffset = Array.FindIndex(definition, l => !l.TrimStart().StartsWith('@')); + yield return new Cell(kind, position, name, Program(definition), Program(placeholder), + prelude.Length + before.Length + keywordOffset + 1); + } + } + } + } + + [Fact] + public void Cells_CoverEveryAxis_AnchoredToLiterals() + { + Assert.Equal(7, Kinds.Length); + Assert.Equal(4, Positions.Length); + Assert.Equal(3, Names.Length); + Assert.Equal(7 * 4 * 3, Cells().Count()); + Assert.Equal(7 * 4 * 3, Cells().Select(c => c.Id).Distinct().Count()); + } + + [Fact] + public void EveryTypeDefinitionWritesOnlyItsOwnSymbol() + { + var api = new CompilerApi(); + var problems = new List(); + var refused = 0; + foreach (var cell in Cells()) + { + var result = api.Analyze(cell.Source, CancellationToken.None); + var control = api.Analyze(cell.Control, CancellationToken.None); + var codes = result.Diagnostics.Select(d => d.Code ?? "-").ToList(); + var line = $"{cell.Id}: {Fingerprint(result.Diagnostics)}"; + _output.WriteLine(line); + + if (codes.Any(c => c.StartsWith("SPY09", StringComparison.Ordinal))) + problems.Add($"{line} — an internal error"); + + var refusal = result.Diagnostics.Where(d => d.Code == "SPY0202" && d.Line == cell.DefinitionLine).ToList(); + if (refusal.Count == 1) + refused++; + else + problems.Add($"{line} — expected one SPY0202 refusal at line {cell.DefinitionLine}"); + + // The definition's own line carries its refusal and, for `str`, the builtin-shadowing warning (SPY0453). + var others = Fingerprint(result.Diagnostics.Where(d => d.Line != cell.DefinitionLine)); + var controlFingerprint = Fingerprint(control.Diagnostics); + if (others != controlFingerprint) + problems.Add($"{line} — the rest differs from the control [{controlFingerprint}]: the definition wrote a symbol it did not declare"); + + if (cell.Name == "Foo") + { + var members = FooMembers(result); + var controlMembers = FooMembers(control); + if (members != controlMembers) + problems.Add($"{cell.Id}: module-level Foo is [{members}] / control [{controlMembers}]"); + } + } + + _output.WriteLine($"{refused} cells refused with SPY0202"); + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + private static string Fingerprint(IEnumerable diagnostics) + => string.Join(" ", diagnostics.Select(d => $"{d.Code ?? "-"}@{d.Line}:{d.Column}").OrderBy(s => s, StringComparer.Ordinal)); + + /// The member names of the module-level Foo (the prelude's first line, column 1). + private static string FooMembers(SemanticResult result) + { + var foo = result.SemanticQuery?.FindSymbolByDeclaration("Foo", 1, 1) as global::Sharpy.Compiler.Semantic.TypeSymbol + ?? throw new InvalidOperationException("the module-level class Foo has no symbol"); + return string.Join(",", foo.Fields.Select(f => $"field:{f.Name}={f.ConstantValue}/{f.IsCompileTimeConstant}") + .Concat(foo.Properties.Select(p => $"property:{p.Name}")) + .Concat(foo.Methods.Select(m => $"method:{m.Name}")) + .Concat(foo.Constructors.Select(c => $"ctor:{c.Parameters.Count}")) + .Append($"dataclass:{foo.IsDataclass}") + .OrderBy(s => s, StringComparer.Ordinal)); + } +} diff --git a/src/Sharpy.Compiler.Tests/Semantic/Validation/BracketAttributeResolutionTests.cs b/src/Sharpy.Compiler.Tests/Semantic/Validation/BracketAttributeResolutionTests.cs index 1ee585339..34a0af8d8 100644 --- a/src/Sharpy.Compiler.Tests/Semantic/Validation/BracketAttributeResolutionTests.cs +++ b/src/Sharpy.Compiler.Tests/Semantic/Validation/BracketAttributeResolutionTests.cs @@ -296,7 +296,7 @@ public void ResolvesToClrType_IsFalseForANameNothingDeclares() { // Instance-scoped since #1493: absence verdicts must not outlive the compilation that // reached them. One resolver here IS one compilation. - var resolver = new ClrAttributeResolver(); + var resolver = new ClrAttributeResolver(AssemblyVisibility.FrameworkOnly); Assert.False(resolver.ResolvesToClrType("NoSuchAttrXyz")); Assert.True(resolver.ResolvesToClrType("Obsolete")); } diff --git a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs index 954b4315a..6f662db0b 100644 --- a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs +++ b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.CompilationUnit.cs @@ -587,16 +587,19 @@ private static string ResolveClrTypeName(Symbol? symbol, string fallbackName) /// private static bool IsClrTypeGenericInNamespace(string namespaceName, string typeName) { - foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies()) + // Rule-2 cell (#2250 findings ledger; no tracker yet): this is the emitter asking reflection a + // semantic question, which belongs in semantic analysis as a materialized fact. Until it moves, + // it asks the FRAMEWORK visibility — a process constant — instead of every assembly the process + // has loaded; its only caller passes a .NET framework namespace (IsNetFrameworkNamespace). + var framework = Discovery.AssemblyVisibility.FrameworkOnly; + + // Try with generic arity suffixes `1 through `4 (covers most BCL types) + for (int arity = 1; arity <= 4; arity++) { - // Try with generic arity suffixes `1 through `4 (covers most BCL types) - for (int arity = 1; arity <= 4; arity++) - { - var fullName = $"{namespaceName}.{typeName}`{arity}"; - var type = assembly.GetType(fullName, throwOnError: false); - if (type != null && type.IsGenericTypeDefinition) - return true; - } + var fullName = $"{namespaceName}.{typeName}`{arity}"; + var type = framework.ResolveType(fullName); + if (type != null && type.IsGenericTypeDefinition) + return true; } return false; diff --git a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.TypeDeclarations.cs b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.TypeDeclarations.cs index 8dc525403..97ec140bb 100644 --- a/src/Sharpy.Compiler/CodeGen/RoslynEmitter.TypeDeclarations.cs +++ b/src/Sharpy.Compiler/CodeGen/RoslynEmitter.TypeDeclarations.cs @@ -2149,7 +2149,7 @@ private ClassDeclarationSyntax GenerateUnionCaseClass( return caseDecl; } - private MethodDeclarationSyntax GenerateDeconstructMethod(List fields) + private MethodDeclarationSyntax GenerateDeconstructMethod(IReadOnlyList fields) { var outParams = fields.Select(f => Parameter(EscapedIdentifier(NameMangler.ToCamelCase(f.Name))) diff --git a/src/Sharpy.Compiler/Compiler.cs b/src/Sharpy.Compiler/Compiler.cs index a23212c15..b674968c5 100644 --- a/src/Sharpy.Compiler/Compiler.cs +++ b/src/Sharpy.Compiler/Compiler.cs @@ -131,15 +131,15 @@ public ProjectCompilationResult CompileProject(ProjectConfig projectConfig, Canc _discoveryTime += discoveryStopwatch.Elapsed; } - // A reference that resolves to no file is SPY0305 on the registry (the constructor's and the - // NuGet loads above). Refuse here, BEFORE the project pipeline runs: on this route + // A reference that resolves to no file (SPY0305) or cannot be loaded as given (SPY0306) is a + // registry error (the constructor's and the NuGet loads above; R-HI). Refuse here, BEFORE the project pipeline runs: on this route // ProjectCompiler.Compile itself writes the assembly, so a merge after it would leave the // output on disk beside a failed result (#2223). Same shape as CompileAsSyntheticProject. - var missingReferences = _moduleRegistry?.MissingReferenceDiagnostics(); - if (missingReferences is { Count: > 0 }) + var referenceErrors = _moduleRegistry?.ReferenceDiagnostics(); + if (referenceErrors is { Count: > 0 }) { var refused = new DiagnosticBag(); - foreach (var diagnostic in missingReferences) + foreach (var diagnostic in referenceErrors) refused.Add(diagnostic); return new ProjectCompilationResult diff --git a/src/Sharpy.Compiler/CompilerApi.cs b/src/Sharpy.Compiler/CompilerApi.cs index 4c21f0e6a..bff8860b6 100644 --- a/src/Sharpy.Compiler/CompilerApi.cs +++ b/src/Sharpy.Compiler/CompilerApi.cs @@ -130,16 +130,17 @@ private CompileResult CompileAsSyntheticProject( var config = SyntheticProject.BuildConfig(source, entryFilePath, options, _logger); var registry = BuildModuleRegistry(config); - // A reference that resolves to no file is SPY0305 on the registry; this path built the - // registry and never read it, so `--reference does/not/exist.dll` compiled silently (#2173). - // Refuse before compiling, as for a missing input file — nothing is written. - var missingReferences = registry?.MissingReferenceDiagnostics(); - if (missingReferences is { Count: > 0 }) + // A reference that resolves to no file (SPY0305) or cannot be loaded as given (SPY0306) is a + // registry error; this path built the registry and never read it, so + // `--reference does/not/exist.dll` compiled silently (#2173). Refuse before compiling, as for + // a missing input file — nothing is written. Every registry error reaches every door (R-HI). + var referenceErrors = registry?.ReferenceDiagnostics(); + if (referenceErrors is { Count: > 0 }) { return new CompileResult { Success = false, - Diagnostics = missingReferences, + Diagnostics = referenceErrors, GeneratedCSharpFiles = new Dictionary(), UsedAssemblyPaths = new HashSet(), ProjectConfig = config, @@ -471,20 +472,21 @@ private SemanticResult AnalyzeCore(string source, string entryPath, bool nullify : null; } - // A reference that resolves to no file is SPY0305 on the (cached) registry, which the analysis - // never reads; the single-file analyze door dropped it, as AnalyzeProject did (#2223). + // A registry error (SPY0305 no file, SPY0306 not loadable as given) is on the (cached) + // registry, which the analysis never reads; the single-file analyze door dropped it, as + // AnalyzeProject did (#2223, R-HI). // Analysis writes nothing, so merging after it is enough. - var missingReferences = registry?.MissingReferenceDiagnostics() ?? Array.Empty(); - var diagnostics = missingReferences.Count == 0 + var referenceErrors = registry?.ReferenceDiagnostics() ?? Array.Empty(); + var diagnostics = referenceErrors.Count == 0 ? analysis.Diagnostics.GetAll() - : analysis.Diagnostics.GetAll().Concat(missingReferences).ToList(); + : analysis.Diagnostics.GetAll().Concat(referenceErrors).ToList(); return new SemanticResult { // Diagnostics and success come from the whole analysis (project bag), matching the // former single-file driver and the compile path; the AST/semantic/symbol/comment // artifacts are the entry file's per-unit view. - Success = !analysis.Diagnostics.HasErrors && missingReferences.Count == 0, + Success = !analysis.Diagnostics.HasErrors && referenceErrors.Count == 0, Diagnostics = diagnostics, Ast = entryResult?.Ast, SemanticInfo = entryResult?.SemanticInfo, @@ -522,17 +524,17 @@ public Project.ProjectAnalysisResult AnalyzeProject( emitterFactory: _emitterFactory); var result = compiler.AnalyzeProject(config, cancellationToken); - // A that resolves to no file is SPY0305 on the registry, which the analysis - // never reads (#2223). Analysis writes nothing, so merging after it is enough; the entry is + // A that resolves to no file (SPY0305) or cannot be loaded as given (SPY0306) is a + // registry error, which the analysis never reads (#2223, R-HI). Analysis writes nothing, so merging after it is enough; the entry is // file-less, so the per-file readers place it on UnattributedTarget (D3/D5 of #2242). The // registry is the cached one: its diagnostics were recorded when it was built, so a warm // call reports them again, and a reference appearing or vanishing changes its mtime in the // cache key, which rebuilds the registry. - var missingReferences = registry?.MissingReferenceDiagnostics(); - if (missingReferences is not { Count: > 0 }) + var referenceErrors = registry?.ReferenceDiagnostics(); + if (referenceErrors is not { Count: > 0 }) return result; - foreach (var diagnostic in missingReferences) + foreach (var diagnostic in referenceErrors) result.Diagnostics.Add(diagnostic); return new Project.ProjectAnalysisResult( @@ -638,6 +640,12 @@ private CompilerOptions WithMergedReferences(CompilerOptions options) /// registries essential there. MaterializeCodeGenInfo folds bridge tables into /// the without writing to symbols (#1633), so the /// cached is returned directly — no per-compilation clone. + /// The contract that makes sharing it sound: the master is FROZEN after construction — + /// no registration state survives its constructor, its lazily filled caches are + /// GetOrAdd with a value that is a function of the key, and every symbol it built + /// refuses a write (). An analysis therefore answers on + /// a warm instance exactly as on a cold one; a user definition declares its own symbol and + /// never writes the builtin of the same name (#2250). /// internal (ModuleRegistry? Registry, BuiltinRegistry Builtins) GetOrBuildAnalysisContext( ProjectConfig config) @@ -650,8 +658,9 @@ private CompilerOptions WithMergedReferences(CompilerOptions options) return (_analysisCache.Registry, _analysisCache.Builtins); } - var builtins = new BuiltinRegistry(_logger); + // The registry first: the builtin registry's CLR fallback sees THIS reference set's visibility (#2250). var registry = BuildModuleRegistry(config); + var builtins = new BuiltinRegistry(_logger, registry?.Visibility); var entry = new AnalysisCacheEntry(key, registry, builtins); lock (_analysisCacheLock) diff --git a/src/Sharpy.Compiler/Diagnostics/CrashBundleWriter.cs b/src/Sharpy.Compiler/Diagnostics/CrashBundleWriter.cs index ecce0388a..c041ddaa4 100644 --- a/src/Sharpy.Compiler/Diagnostics/CrashBundleWriter.cs +++ b/src/Sharpy.Compiler/Diagnostics/CrashBundleWriter.cs @@ -92,33 +92,59 @@ internal static bool IsNonSourceSegment(string relativePath) public const string SpanDataKey = "crash-span"; /// - /// Writes a crash bundle under <outputDirectory>/.sharpy-crash/<timestamp>/. + /// The marker a writer creates with inside a candidate bundle + /// directory to claim it. succeeds for a + /// directory that already exists, so it cannot tell two writers apart; a create-new file is the + /// one create-exclusive primitive .NET offers on every platform (#2250). + /// + internal const string OwnerMarkerName = ".owner"; + + /// How many suffixed candidates a writer tries before giving up (returning null). + private const int MaxBundleCandidates = 100; + + /// + /// Writes a crash bundle under <outputDirectory>/.sharpy-crash/<timestamp>-<pid>[-n]/. /// Returns the bundle directory path on success, or null if writing failed for any reason /// (the caller must treat a null result as "no bundle" and never let it interrupt error reporting). /// public static string? TryWrite(string? outputDirectory, CrashBundleRequest request) + => TryWrite(outputDirectory, request, () => DateTime.UtcNow); + + /// + /// Test seam for : the clock that names the + /// bundle directory is a parameter, so a test can pin every writer to one tick and make a + /// same-millisecond collision deterministic (#2250). A parameter rather than a settable static + /// hook — a mutable static would be a StaticStateConformanceTests rule-1 violation. + /// + internal static string? TryWrite(string? outputDirectory, CrashBundleRequest request, Func clock) { try { var root = ResolveCrashRoot(outputDirectory); - var timestamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture); - var bundleDir = Path.Combine(root, timestamp); - - // Extremely unlikely collision guard (two crashes in the same millisecond). - var suffix = 0; - var candidate = bundleDir; - while (Directory.Exists(candidate) && suffix < 100) + var timestamp = clock().ToString("yyyyMMdd-HHmmss-fff", CultureInfo.InvariantCulture); + var stem = Path.Combine( + root, timestamp + "-" + Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + + // Two crashes in one millisecond — in one process (a compile server, an LSP, parallel + // tests) or two — must get two bundles. "Directory.Exists, then CreateDirectory" let two + // writers pick one directory and overwrite each other's report (#2250). The process id + // separates processes; within one, a writer owns a candidate only if it created the + // owner marker with CreateNew, and moves to the next suffix when another writer did. + for (var suffix = 0; suffix < MaxBundleCandidates; suffix++) { - suffix++; - candidate = $"{bundleDir}-{suffix}"; - } - bundleDir = candidate; - Directory.CreateDirectory(bundleDir); + var candidate = suffix == 0 ? stem : $"{stem}-{suffix}"; + Directory.CreateDirectory(candidate); + if (!TryClaim(candidate)) + { + continue; + } - WriteReport(bundleDir, request); - WriteSources(bundleDir, request); + WriteReport(candidate, request); + WriteSources(candidate, request); + return candidate; + } - return bundleDir; + return null; } catch { @@ -147,6 +173,24 @@ internal static bool IsNonSourceSegment(string relativePath) return parts.Count == 0 ? null : string.Join(" @ ", parts); } + /// + /// Claims by creating its owner marker exclusively. False when + /// another writer already created it (the directory is theirs). + /// + private static bool TryClaim(string bundleDir) + { + try + { + using var marker = new FileStream( + Path.Combine(bundleDir, OwnerMarkerName), FileMode.CreateNew, FileAccess.Write, FileShare.None); + return true; + } + catch (IOException) when (File.Exists(Path.Combine(bundleDir, OwnerMarkerName))) + { + return false; + } + } + private static string ResolveCrashRoot(string? outputDirectory) { var baseDir = !string.IsNullOrWhiteSpace(outputDirectory) diff --git a/src/Sharpy.Compiler/Discovery/AssemblyVisibility.cs b/src/Sharpy.Compiler/Discovery/AssemblyVisibility.cs new file mode 100644 index 000000000..95f452839 --- /dev/null +++ b/src/Sharpy.Compiler/Discovery/AssemblyVisibility.cs @@ -0,0 +1,657 @@ +extern alias SharpyRT; + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.Loader; +using Sharpy.Compiler.Shared; + +namespace Sharpy.Compiler.Discovery; + +/// +/// The ONE answer to "which CLR types and namespaces exist" for a compilation (#2250, P54 Design +/// Decision 1): {the framework set} ∪ {the assemblies THIS registry loaded}. Every type-name, +/// namespace and generic-definition lookup the compiler makes against CLR metadata goes through an +/// instance of this class — never through AppDomain.CurrentDomain.GetAssemblies(), the static +/// Type.GetType(string) or Assembly.LoadFrom, which answer "whatever this process has +/// loaded so far" and so made a compile a function of the process's history. +/// +/// The framework set is a process CONSTANT (option A of Design Decision 1): a process +/// loads, once, the SHARED FRAMEWORK — every trusted-platform assembly that +/// Microsoft.NETCore.App ships, i.e. whose file name is in the runtime directory CoreLib was +/// loaded from — plus Sharpy.Core, and indexes their public types by namespace and by (simple +/// name, arity). It is NOT the host's own application assemblies: a host's trusted-platform list also +/// carries its deps.json packages (sharpyc: System.CommandLine, Microsoft.CodeAnalysis*, +/// Microsoft.Extensions.*; the LSP: OmniSharp's; a test host: xunit's), which a compiled program does +/// not ship with and which differ per host. A name filter (System.*/Microsoft.*) made the +/// framework a compile saw a function of WHICH host ran it, and turned an import of a host package +/// into a compile that crashes at run (#2250). A host package a program wants comes in by reference +/// (-r) and loads into that registry's reference context like any other. The trusted-platform +/// list and the runtime directory are fixed at process start, so the index is a function of nothing a +/// compile can change, and it is the same set in every host on one runtime. There is no namespace-driven loading left to +/// race (the former ModuleRegistry._loadedRuntimeNamespaces and +/// ClrAttributeResolver._probedNamespaces gates are subsumed), and the namespace-less queries +/// (a generic definition by display name, "does any assembly declare this namespace", the generic +/// twin of a non-generic CLR type) answer from the whole constant set, never from "the framework +/// assemblies loaded so far". +/// +/// The registry's own set is the references its ModuleRegistry loaded, in load +/// order. A reference whose simple name is a framework assembly's (Sharpy.Core is a default +/// reference) is not added: the framework copy is the one identity. A compile with no registry sees +/// . +/// +/// Lookup caches live on the instance and are replaced when the own set grows, so a name probed +/// before a reference arrived is not answered from the smaller world afterwards. +/// +[ThreadSafe] +internal sealed class AssemblyVisibility +{ + /// + /// The framework set and its index — built once per process on first use. Key-pure: it has no + /// key, and its value is a function of the trusted-platform list fixed at process start. + /// + private static readonly Lazy s_framework = + new(BuildFrameworkIndex, LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// The visibility of a compilation with no registry, of the master builtin registry's CLR + /// fallback (shared by every compile of one reference set) and of the emitter's one reflection + /// site: the framework set alone. Its caches are process-wide by being on a static instance — + /// and key-pure, because the framework set is constant. + /// + public static AssemblyVisibility FrameworkOnly { get; } = new(isFrameworkOnly: true); + + private readonly bool _isFrameworkOnly; + private readonly object _gate = new(); + private volatile State _state; + + /// A visibility whose own set starts empty; the owning registry adds its references. + public AssemblyVisibility() : this(isFrameworkOnly: false) + { + } + + private AssemblyVisibility(bool isFrameworkOnly) + { + _isFrameworkOnly = isFrameworkOnly; + _state = new State(ImmutableArray.Empty); + } + + /// The framework index (first use builds it). + private static TypeIndex Framework => s_framework.Value; + + /// The assemblies this registry loaded, in load order (framework assemblies excluded). + public ImmutableArray OwnAssemblies => _state.Own; + + /// Every visible assembly: the framework set first, then the own set in load order. + public IEnumerable Assemblies => Framework.Assemblies.Concat(_state.Own); + + /// + /// Adds an assembly this registry loaded. A framework assembly (by simple name) is not added — the + /// framework copy is the one identity — and an assembly already in the own set is a no-op. + /// Returns true when the own set grew. + /// + internal bool Add(Assembly assembly) + { + if (_isFrameworkOnly) + throw new InvalidOperationException( + "AssemblyVisibility.FrameworkOnly is constant; a registry owns its own visibility."); + + var name = SafeSimpleName(assembly); + if (name == null || Framework.BySimpleName.ContainsKey(name)) + return false; + + lock (_gate) + { + var state = _state; + if (state.Own.Contains(assembly)) + return false; + // A NEW state: every cached answer was reached against the smaller set. + _state = new State(state.Own.Add(assembly)); + return true; + } + } + + /// + /// The visible assembly named : the framework's, else the first in the + /// own set. Null when neither has one. + /// + public Assembly? FindAssembly(string simpleName) + { + if (Framework.BySimpleName.TryGetValue(simpleName, out var framework)) + return framework; + foreach (var own in _state.Own) + { + if (string.Equals(SafeSimpleName(own), simpleName, StringComparison.Ordinal)) + return own; + } + return null; + } + + /// The framework assembly named , or null. + public static Assembly? FindFrameworkAssembly(string simpleName) + => Framework.BySimpleName.TryGetValue(simpleName, out var assembly) ? assembly : null; + + /// + /// Resolves a CLR type by a stored name — a namespace-qualified full name (System.IO.File, + /// Sharpy.SocketModule+Error, System.Collections.Generic.List`1) or an + /// assembly-qualified one (Sharpy.Foo, Sharpy.Stdlib, Version=…), whose assembly must be + /// visible. Cached per name, nulls included; the cache is replaced when the own set grows. + /// + public Type? ResolveType(string name) + { + if (string.IsNullOrEmpty(name)) + return null; + + var state = _state; + return state.ResolveCache.GetOrAdd(name, n => ResolveTypeUncached(state, n)); + } + + /// + /// The public top-level types of : the framework's, then the own + /// set's in load order. + /// + public IReadOnlyList TypesInNamespace(string namespaceName) + { + var framework = Framework.TypesByNamespace.TryGetValue(namespaceName, out var f) + ? f + : ImmutableArray.Empty; + var own = _state.OwnIndex.Value.TypesByNamespace.TryGetValue(namespaceName, out var o) + ? o + : ImmutableArray.Empty; + return own.IsEmpty ? framework : framework.AddRange(own); + } + + /// Whether any visible assembly declares a public top-level type in . + public bool NamespaceExists(string namespaceName) + => Framework.TypesByNamespace.ContainsKey(namespaceName) + || _state.OwnIndex.Value.TypesByNamespace.ContainsKey(namespaceName); + + /// + /// The visible generic type definitions whose arity-stripped simple name is + /// and whose generic-argument count is — + /// framework first, then the own set, each in index order. + /// + public IEnumerable GenericDefinitionsNamed(string simpleName, int arity) + { + var key = (simpleName, arity); + var own = _state.OwnIndex.Value; + if (Framework.GenericDefinitions.TryGetValue(key, out var framework)) + { + foreach (var t in framework) + yield return t; + } + if (own.GenericDefinitions.TryGetValue(key, out var ownDefinitions)) + { + foreach (var t in ownDefinitions) + yield return t; + } + } + + /// + /// Last-resort resolution of a CLR generic definition from a bare Sharpy DISPLAY name (no + /// GenericType.ClrOriginTypeName, no symbol) — the shape a return type takes after losing + /// its identity through the overload-index → builtin-substitution chain (#1517). Probes + /// System.Collections.Generic first, then any visible generic definition of that simple + /// name and arity. Cached per name + arity, nulls included. + /// + public Type? ResolveGenericDefinitionByDisplayName(string displayName, int arity) + { + var state = _state; + return state.DisplayNameCache.GetOrAdd((displayName, arity), key => + { + var candidateName = key.Name.Contains('`', StringComparison.Ordinal) + ? key.Name + : key.Name + "`" + key.Arity; + var scg = ResolveTypeUncached(state, "System.Collections.Generic." + candidateName); + if (scg != null) + return scg; + return GenericDefinitionsNamed(ClrNameHelper.StripArity(key.Name), key.Arity).FirstOrDefault(); + }); + } + + private Type? ResolveTypeUncached(State state, string name) + { + try + { + if (IsAssemblyQualifiedOrConstructed(name)) + { + // The assembly qualifier binds against the VISIBLE set by simple name — never against + // the process's binder — and an unqualified (generic-argument) name is searched in the + // visible set too. + return TypeNameResolution( + name, + assemblyName => assemblyName.Name is { } simple + ? FindAssembly(simple) ?? LoadDependencyOfOwn(state, assemblyName) + : null, + (assembly, typeName) => assembly != null + ? SafeGetType(assembly, typeName) + : FindTypeInVisibleSet(state, typeName)); + } + + return FindTypeInVisibleSet(state, name); + } + catch (Exception ex) when (ex is ArgumentException or TypeLoadException or IOException + or BadImageFormatException) + { + return null; + } + } + + /// + /// An assembly-qualified name in a reference's own signatures may name one of that reference's + /// DEPENDENCIES (a stdlib method returning a type from a package the stdlib uses). It binds the way + /// the runtime binds it for that reference — through the reference's own load context — and only + /// when an own assembly actually references the name; the framework is never asked to guess. + /// + private static Assembly? LoadDependencyOfOwn(State state, AssemblyName dependency) + { + foreach (var own in state.Own) + { + AssemblyName[] referenced; + try + { + referenced = own.GetReferencedAssemblies(); + } + catch (Exception ex) when (ex is BadImageFormatException or IOException or NotSupportedException) + { + continue; + } + + if (!referenced.Any(r => string.Equals(r.Name, dependency.Name, StringComparison.Ordinal))) + continue; + + try + { + return AssemblyLoadContext.GetLoadContext(own)?.LoadFromAssemblyName(dependency); + } + catch (Exception ex) when (ex is IOException or BadImageFormatException) + { + return null; + } + } + return null; + } + + private static Type? FindTypeInVisibleSet(State state, string fullName) + { + foreach (var assembly in Framework.Assemblies) + { + var found = SafeGetType(assembly, fullName); + if (found != null) + return found; + } + foreach (var assembly in state.Own) + { + var found = SafeGetType(assembly, fullName); + if (found != null) + return found; + } + return null; + } + + /// + /// The runtime's type-name parser with BOTH resolvers supplied, so neither the assembly qualifier + /// nor an unqualified (generic-argument) name is bound against the process. The compiler's one + /// static Type.GetType, key-pure in the visible set (the scan guard's roster row). + /// + private static Type? TypeNameResolution( + string name, + Func assemblyResolver, + Func typeResolver) + => Type.GetType( + name, + assemblyResolver, + (assembly, typeName, _) => typeResolver(assembly, typeName), + throwOnError: false); + + private static bool IsAssemblyQualifiedOrConstructed(string name) + => name.Contains(',', StringComparison.Ordinal) || name.Contains('[', StringComparison.Ordinal); + + /// + /// assembly.GetType(fullName), null when the assembly cannot answer — an assembly whose own + /// references do not resolve contributes no type. + /// + internal static Type? SafeGetType(Assembly assembly, string fullName) + { + try + { + return assembly.GetType(fullName, throwOnError: false); + } + catch (Exception ex) when (ex is FileNotFoundException or FileLoadException or TypeLoadException + or BadImageFormatException or ArgumentException) + { + return null; + } + } + + // ---- Design Decision 3: a static Type-keyed cache holds only process-stable keys ---- + + /// + /// Whether is WHOLLY in the default load context — its assembly and every + /// constituent (element type, generic arguments, declaring type). A static cache keyed by a type + /// that is not would pin that type's per-registry load context for the life of the process, so + /// such a type is answered uncached (P54 Design Decision 3). A constructed generic needs the walk: + /// Sharpy.List<Greeter>'s own assembly is Sharpy.Core, yet it pins Greeter's context. + /// + internal static bool IsProcessStable(Type type) + { + try + { + if (type.HasElementType) + return type.GetElementType() is { } element && IsProcessStable(element); + + if (!IsProcessStable(type.Assembly)) + return false; + + if (type.IsGenericParameter) + return true; + + if (type.IsConstructedGenericType) + { + foreach (var argument in type.GetGenericArguments()) + { + if (!IsProcessStable(argument)) + return false; + } + } + + return type.DeclaringType is not { } declaring || IsProcessStable(declaring); + } + catch (NotSupportedException) + { + // A type that cannot describe its constituents is not cached. + return false; + } + } + + /// Whether lives in the default load context and is not collectible. + internal static bool IsProcessStable(Assembly assembly) + => !assembly.IsCollectible && AssemblyLoadContext.GetLoadContext(assembly) == AssemblyLoadContext.Default; + + /// + /// cache.GetOrAdd(key, factory) when is process-stable, else + /// factory(key) uncached (Design Decision 3). + /// + internal static TValue GetOrAddIfProcessStable( + ConcurrentDictionary cache, TKey key, Type keyType, Func factory) + where TKey : notnull + => IsProcessStable(keyType) ? cache.GetOrAdd(key, factory) : factory(key); + + /// The Assembly-keyed twin of . + internal static TValue GetOrAddIfProcessStable( + ConcurrentDictionary cache, Assembly key, Func factory) + => IsProcessStable(key) ? cache.GetOrAdd(key, factory) : factory(key); + + /// Writes under only when is process-stable. + internal static void SetIfProcessStable( + ConcurrentDictionary cache, TKey key, Type keyType, TValue value) + where TKey : notnull + { + if (IsProcessStable(keyType)) + cache[key] = value; + } + + // ---- Names ---- + + /// + /// The simple name of , or null when it cannot describe itself — a + /// transient assembly whose file has been replaced throws + /// from (#1840); one unreadable assembly is never a reason to + /// fail a compilation. + /// + internal static string? SafeSimpleName(Assembly assembly) + { + try + { + return assembly.GetName().Name; + } + catch (Exception ex) when (ex is BadImageFormatException or FileNotFoundException + or ReflectionTypeLoadException or NotSupportedException) + { + return null; + } + } + + /// The simple names of , skipping any that cannot describe itself (#1840). + internal static HashSet SafeSimpleNames(IEnumerable assemblies) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var assembly in assemblies) + { + if (SafeSimpleName(assembly) is { } name) + names.Add(name); + } + return names; + } + + // ---- The framework set ---- + + /// + /// The shared framework's directory — the Microsoft.NETCore.App runtime directory CoreLib was + /// loaded from — or null in a host whose CoreLib has no file (browser-wasm, a single-file bundle). + /// Computed, not stored: a function of the process's CoreLib. + /// + internal static string? SharedFrameworkDirectory + => typeof(object).Assembly.Location is { Length: > 0 } coreLib + && Path.GetDirectoryName(Path.GetFullPath(coreLib)) is { Length: > 0 } directory + ? directory + : null; + + /// + /// Whether the shared framework ships an assembly named — a + /// .dll of that name lies in . Keyed on the runtime's + /// files, never on a name prefix: the host's own System.*/Microsoft.* packages are not + /// the framework (#2250). + /// + internal static bool IsSharedFrameworkName(string simpleName) + => SharedFrameworkDirectory is { } directory + && simpleName.Length > 0 + && simpleName.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 + && File.Exists(Path.Combine(directory, simpleName + ".dll")); + + /// The framework index's size and first-use cost (Phase 2 task 6's record). + internal static (int Assemblies, int Types, long Milliseconds) FrameworkIndexStatistics + => (Framework.Assemblies.Length, Framework.TypeCount, Framework.BuildMilliseconds); + + private static TypeIndex BuildFrameworkIndex() + { + var stopwatch = Stopwatch.StartNew(); + var assemblies = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + void Include(Assembly? assembly) + { + if (assembly == null || assembly.IsDynamic) + return; + if (SafeSimpleName(assembly) is { } name && seen.Add(name)) + assemblies.Add(assembly); + } + + // CoreLib first (what the static Type.GetType searched first), then Sharpy.Core — the runtime + // every compiled program ships with. NOT the compiler itself nor its dependencies (Roslyn, + // Microsoft.Extensions.*): those are the host's, not the program's. + Include(typeof(object).Assembly); + Include(typeof(SharpyRT::Sharpy.Builtins).Assembly); + + if (SharedFrameworkDirectory != null) + { + // The shared framework: the trusted-platform names Microsoft.NETCore.App ships, in ordinal + // name order — a process constant, the same in every host on this runtime. Loaded BY NAME, + // so a framework assembly the host's deps.json rolls forward binds to the one copy the + // process uses. + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa) + { + var names = tpa.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(p => Path.GetFileNameWithoutExtension(p) ?? string.Empty) + .Where(n => n.Length > 0 && IsSharedFrameworkName(n)) + .Distinct(StringComparer.Ordinal) + .OrderBy(n => n, StringComparer.Ordinal); + foreach (var name in names) + { + if (!seen.Contains(name)) + Include(LoadFrameworkAssembly(new AssemblyName(name))); + } + } + } + else + { + // A host with no file-backed runtime directory (the browser-wasm Playground, a single-file + // bundle) cannot tell the shared framework from its own packages by file. It gets the + // compiler's direct dependencies named System / System.* / netstandard / mscorlib — the + // framework facades the compiler was built against. Microsoft.* is the host's (Roslyn, + // Microsoft.Extensions.*) and is never included. Name-keyed only because no file exists to + // key on (#2250). + var compiler = typeof(AssemblyVisibility).Assembly; + foreach (var dependency in compiler.GetReferencedAssemblies().OrderBy(n => n.Name, StringComparer.Ordinal)) + { + if (dependency.Name is "System" or "netstandard" or "mscorlib" + || dependency.Name?.StartsWith("System.", StringComparison.Ordinal) == true) + { + Include(LoadFrameworkAssembly(dependency)); + } + } + } + + var index = TypeIndex.Build(assemblies); + stopwatch.Stop(); + return index with { BuildMilliseconds = stopwatch.ElapsedMilliseconds }; + } + + /// + /// Loads a framework assembly by NAME into the default context — the trusted-platform binding, + /// a process constant (the copy every by-name bind in the process gets). + /// + private static Assembly? LoadFrameworkAssembly(AssemblyName name) + { + try + { + return Assembly.Load(name); + } + catch (Exception ex) when (ex is IOException or BadImageFormatException + or UnauthorizedAccessException or ArgumentException) + { + // An assembly that will not load contributes no type. + return null; + } + } + + /// An immutable index over an ordered assembly list. + private sealed record TypeIndex( + ImmutableArray Assemblies, + ImmutableDictionary BySimpleName, + ImmutableDictionary> TypesByNamespace, + ImmutableDictionary<(string Name, int Arity), ImmutableArray> GenericDefinitions, + int TypeCount) + { + public long BuildMilliseconds { get; init; } + + public static TypeIndex Build(IEnumerable ordered) + { + var assemblies = ordered.ToImmutableArray(); + var bySimpleName = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + var byNamespace = new Dictionary.Builder>(StringComparer.Ordinal); + var generics = new Dictionary<(string, int), ImmutableArray.Builder>(); + var typeCount = 0; + + foreach (var assembly in assemblies) + { + if (SafeSimpleName(assembly) is { } simple && !bySimpleName.ContainsKey(simple)) + bySimpleName[simple] = assembly; + + foreach (var type in VisibleTypes(assembly)) + { + typeCount++; + if (type.IsPublic && type.Namespace is { } ns) + { + if (!byNamespace.TryGetValue(ns, out var inNamespace)) + byNamespace[ns] = inNamespace = ImmutableArray.CreateBuilder(); + inNamespace.Add(type); + } + + if (type.IsGenericTypeDefinition) + { + var key = (ClrNameHelper.StripArity(type.Name), type.GetGenericArguments().Length); + if (!generics.TryGetValue(key, out var named)) + generics[key] = named = ImmutableArray.CreateBuilder(); + named.Add(type); + } + } + } + + return new TypeIndex( + assemblies, + bySimpleName.ToImmutable(), + byNamespace.ToImmutableDictionary(kv => kv.Key, kv => kv.Value.ToImmutable(), StringComparer.Ordinal), + generics.ToImmutableDictionary(kv => kv.Key, kv => kv.Value.ToImmutable()), + typeCount); + } + + /// + /// The assembly's visible (public, or nested public) types; a partially loadable assembly + /// contributes what loads. + /// + private static IEnumerable VisibleTypes(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetExportedTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(t => t != null).Select(t => t!).ToArray(); + } + catch (Exception ex) when (ex is FileNotFoundException or FileLoadException or TypeLoadException + or BadImageFormatException or NotSupportedException) + { + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException inner) + { + types = inner.Types.Where(t => t != null).Select(t => t!).ToArray(); + } + catch (Exception inner) when (inner is FileNotFoundException or FileLoadException + or TypeLoadException or BadImageFormatException + or NotSupportedException) + { + return Array.Empty(); + } + } + + return types.Where(IsVisibleSafe); + } + + private static bool IsVisibleSafe(Type type) + { + try + { + return type.IsVisible; + } + catch (Exception ex) when (ex is TypeLoadException or FileNotFoundException or FileLoadException) + { + return false; + } + } + } + + /// + /// One generation of the own set and the caches reached against it. Replaced wholesale when the + /// own set grows, so no cached answer outlives the set it was computed from. + /// + private sealed class State + { + public State(ImmutableArray own) + { + Own = own; + OwnIndex = new Lazy(() => TypeIndex.Build(own), LazyThreadSafetyMode.ExecutionAndPublication); + } + + public ImmutableArray Own { get; } + public Lazy OwnIndex { get; } + public ConcurrentDictionary ResolveCache { get; } = new(StringComparer.Ordinal); + public ConcurrentDictionary<(string Name, int Arity), Type?> DisplayNameCache { get; } = new(); + } +} diff --git a/src/Sharpy.Compiler/Discovery/BuiltinExceptionSurface.cs b/src/Sharpy.Compiler/Discovery/BuiltinExceptionSurface.cs index d9df0d51e..f4d7402b8 100644 --- a/src/Sharpy.Compiler/Discovery/BuiltinExceptionSurface.cs +++ b/src/Sharpy.Compiler/Discovery/BuiltinExceptionSurface.cs @@ -34,7 +34,7 @@ internal static bool IsBuiltinExceptionType(Type type) || (type.Assembly.GetName().Name == "Sharpy.Core" && type.Namespace == "Sharpy")); - _typeCache[type] = result; + AssemblyVisibility.SetIfProcessStable(_typeCache, type, type, result); return result; } @@ -52,7 +52,7 @@ internal static bool IsRefusedMember(Type clrType, string memberName) return cached; bool refused = IsBuiltinExceptionType(clrType) && IsRefusedMemberCore(clrType, memberName); - _refusedCache[(clrType, memberName)] = refused; + AssemblyVisibility.SetIfProcessStable(_refusedCache, (clrType, memberName), clrType, refused); return refused; } diff --git a/src/Sharpy.Compiler/Discovery/CachedModuleDiscovery.cs b/src/Sharpy.Compiler/Discovery/CachedModuleDiscovery.cs index fe303d486..86478024c 100644 --- a/src/Sharpy.Compiler/Discovery/CachedModuleDiscovery.cs +++ b/src/Sharpy.Compiler/Discovery/CachedModuleDiscovery.cs @@ -29,6 +29,9 @@ internal class CachedModuleDiscovery // name. All methods on a generic type must share these instances so that reference // equality holds during generic substitution. private readonly ConcurrentDictionary _skeletonTypeParams = new(); + // The CLR types this discovery may resolve a stored ClrTypeName against: the framework set plus + // the assemblies loaded into it (#2250) — the owning ModuleRegistry's visibility. + private readonly AssemblyVisibility _visibility; /// /// Create a discovery instance using the default cache directory. @@ -42,19 +45,32 @@ public CachedModuleDiscovery() : this(null, null) /// /// Custom cache instance. If null, creates a default cache. /// Optional logger. If null, uses NullLogger. - public CachedModuleDiscovery(OverloadIndexCache? cache, ICompilerLogger? logger = null) + /// + /// The owning registry's ; null gives the discovery its own, whose + /// own set is exactly the assemblies passed to . + /// + public CachedModuleDiscovery(OverloadIndexCache? cache, ICompilerLogger? logger = null, + AssemblyVisibility? visibility = null) { _cache = cache ?? new OverloadIndexCache(null, logger); _builder = new OverloadIndexBuilder(logger); _typeMapper = new ClrTypeBridge(); + _visibility = visibility ?? new AssemblyVisibility(); } + /// The CLR types this discovery resolves stored names against. + internal AssemblyVisibility Visibility => _visibility; + /// /// Load an assembly and discover its functions. /// Uses cache if available, otherwise builds and caches. /// public void LoadAssembly(Assembly assembly) { + // The assembly's types become visible to THIS discovery's resolution (and its registry's) + // before any of its ClrTypeNames are resolved below. + _visibility.Add(assembly); + var identity = AssemblyIdentity.FromAssembly(assembly); // Use Lazy with GetOrAdd to guarantee single factory execution @@ -256,13 +272,9 @@ public List GetModuleTypes(string moduleName) /// private TypeSymbol? CreateSkeletonTypeSymbol(Caching.DiscoveredTypeInfo typeInfo, string? definingModule = null) { - Type? clrType = Type.GetType(typeInfo.ClrTypeName); - if (clrType == null) - { - clrType = AppDomain.CurrentDomain.GetAssemblies() - .Select(a => a.GetType(typeInfo.ClrTypeName)) - .FirstOrDefault(t => t != null); - } + // The stored (assembly-qualified) name resolves against this compilation's visibility — + // the assembly this index was built from is in it — never the process's first copy (#2250). + var clrType = _visibility.ResolveType(typeInfo.ClrTypeName); var typeKind = typeInfo.TypeKind switch { @@ -948,17 +960,9 @@ internal SemanticType ConvertTypeSignature(TypeSignature signature, TypeParamete // Try to resolve the CLR type from the stored ClrTypeName if (!string.IsNullOrEmpty(signature.ClrTypeName)) { - // Try direct Type.GetType first - var clrType = Type.GetType(signature.ClrTypeName); - - // If that fails, search loaded assemblies - // Note: This could be optimized by caching assembly lookups if performance becomes an issue - if (clrType == null) - { - clrType ??= AppDomain.CurrentDomain.GetAssemblies() - .Select(a => a.GetType(signature.ClrTypeName)) - .FirstOrDefault(t => t != null); - } + // Resolved against this compilation's visibility (cached per name there), never the + // process's loaded set (#2250). + var clrType = _visibility.ResolveType(signature.ClrTypeName); if (clrType != null) { diff --git a/src/Sharpy.Compiler/Discovery/Caching/AssemblyIdentity.cs b/src/Sharpy.Compiler/Discovery/Caching/AssemblyIdentity.cs index 0be6af458..eaba72224 100644 --- a/src/Sharpy.Compiler/Discovery/Caching/AssemblyIdentity.cs +++ b/src/Sharpy.Compiler/Discovery/Caching/AssemblyIdentity.cs @@ -20,24 +20,6 @@ internal class AssemblyIdentity /// public string CompilerVersion { get; set; } = string.Empty; - /// - /// Create an identity from an assembly file path. - /// - public static AssemblyIdentity FromAssemblyPath(string assemblyPath) - { - var assembly = Assembly.LoadFrom(assemblyPath); - var assemblyName = assembly.GetName(); - - return new AssemblyIdentity - { - Name = assemblyName.Name ?? Path.GetFileNameWithoutExtension(assemblyPath), - Version = assemblyName.Version?.ToString() ?? "1.0.0", - ContentHash = ComputeFileHash(assemblyPath), - FilePath = assemblyPath, - CompilerVersion = Shared.CompilerIdentity.Version - }; - } - /// /// Create an identity from a loaded assembly. /// diff --git a/src/Sharpy.Compiler/Discovery/ClrAttributeResolver.cs b/src/Sharpy.Compiler/Discovery/ClrAttributeResolver.cs index c47025fdb..7c47260b9 100644 --- a/src/Sharpy.Compiler/Discovery/ClrAttributeResolver.cs +++ b/src/Sharpy.Compiler/Discovery/ClrAttributeResolver.cs @@ -1,9 +1,7 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -50,31 +48,31 @@ internal sealed class ClrAttributeResolver }; /// - /// PRESENCE verdicts: full type name -> a loaded assembly declares it. Process-wide, and safe - /// to keep that way — assemblies do not unload, so a type that exists cannot stop existing. - /// Membership is the verdict; there is no false entry here. + /// The CLR types this compilation can see — the framework set plus its registry's references + /// (#2250). Presence and absence are both answered from it, and it is a function of the + /// compilation's inputs, so neither polarity can be another compilation's verdict. It replaces a + /// process-wide presence cache (s_typePresentCache) and namespace-driven framework loading + /// behind a process-wide gate (_probedNamespaces), both answered from whatever the process + /// had loaded. /// - private static readonly ConcurrentDictionary s_typePresentCache = new(StringComparer.Ordinal); + private readonly AssemblyVisibility _visibility; /// /// ABSENCE verdicts, scoped to ONE resolver instance — that is, to one compilation. /// - /// #1493: this was process-lifetime alongside the presence half, on the premise that the - /// answer is a function of the key and the loaded assembly set, and that the one event changing - /// the latter () cleared it. The premise held only - /// for loads this class performs itself. ModuleRegistry.LoadReference — or any other - /// assembly load — never cleared it, so in a long-lived process (the LSP) a name probed before - /// its assembly was loaded stayed "absent" until restart: an SPY0495 that is order-dependent, - /// silent, and unfixable by editing the file. - /// - /// The two polarities are split rather than the whole cache being scoped, because only - /// one of them can go stale. That keeps the hot path (a name that resolves) process-wide while - /// the verdicts a refusal is built on live exactly as long as the facts they summarize — - /// the instance-scoped shape of TypeChecker._bclMemberAbsenceMemo (#1344), the sibling - /// pattern from the same batch. + /// #1493: an absence verdict that outlived its compilation was an SPY0495 that is + /// order-dependent, silent, and unfixable by editing the file. Scoped here, it lives exactly as + /// long as the facts it summarizes — the instance-scoped shape of + /// TypeChecker._bclMemberAbsenceMemo (#1344). /// private readonly HashSet _typeAbsentThisCompilation = new(StringComparer.Ordinal); + /// A resolver over — the compilation's own. + internal ClrAttributeResolver(AssemblyVisibility visibility) + { + _visibility = visibility; + } + /// /// The project's reference closure, threaded in from the compilation that owns this resolver. /// for single-file and REPL shapes, where the proof @@ -159,13 +157,6 @@ private bool ReferencedAssembliesDeclare(string fullName) : CSharpCompilation.Create("sharpy.reference.probe", references: references); } - /// - /// Namespaces whose shared-framework assemblies have already been pulled in. Monotonic and - /// idempotent, the same shape as ModuleRegistry._loadedRuntimeNamespaces: it gates a - /// one-time load side effect, never per-compile output. - /// - private static readonly ConcurrentDictionary _probedNamespaces = new(StringComparer.Ordinal); - /// /// Every full type name the generated attribute usage could bind to, most likely first: /// the mangled name as written (which is already fully qualified for a dotted bracket @@ -208,56 +199,22 @@ void AddPair(string namespaceName) /// /// True when at least one candidate spelling of names a type /// that exists. False is a proof of absence and is safe to refuse on. - /// TODO(#1492): "safe to refuse on" overstates the evidence base — the proof consults loaded - /// assemblies and the shared framework only, never the project's .spyproj References/ - /// PackageReferences (those are consumed first in Phase 7, AssemblyCompiler). An attribute - /// type living only in a project-referenced assembly is falsely refused with SPY0495. + /// TODO(#1492): "safe to refuse on" overstates the evidence base — the proof consults the + /// compilation's visibility (the framework set and the registry's references) and the project's + /// reference closure; a PackageReference consumed only in Phase 7 (AssemblyCompiler) is not + /// probed, so an attribute type living only there is falsely refused with SPY0495. /// internal bool ResolvesToClrType(string mangledName, IEnumerable? importedNamespaces = null) { - // Materialized because the namespaces are walked twice (candidate construction, then the - // second-chance load) and the caller may hand over a lazy sequence. var namespaces = importedNamespaces as IReadOnlyList ?? importedNamespaces?.ToList(); - var candidates = CandidateTypeNames(mangledName, namespaces); - - foreach (var candidate in candidates) - { - if (TypeExists(candidate)) - return true; - } - - // Nothing among the assemblies this process happens to have touched. The type may still - // live in a shared-framework assembly nothing has loaded yet (System.ComponentModel. - // Primitives, System.IO.FileSystem, ...), so pull those in and retry — the same second - // chance ModuleRegistry.EnsureRuntimeAssembliesLoaded gives namespace imports. Only the - // namespaces that can plausibly be unloaded are probed: the mangled name's own qualifier - // and the file's namespace imports. The always-in-scope namespaces are already resident - // in any process that got far enough to run a validator. - var loadedSomething = false; - var ownQualifier = mangledName.LastIndexOf('.'); - if (ownQualifier > 0) - loadedSomething |= EnsureFrameworkAssembliesLoaded(mangledName.Substring(0, ownQualifier)); - if (namespaces != null) - { - foreach (var ns in namespaces) - { - if (!string.IsNullOrEmpty(ns)) - loadedSomething |= EnsureFrameworkAssembliesLoaded(ns); - } - } - - if (!loadedSomething) - return false; - - // New assemblies invalidate every cached "not found". - InvalidateAbsenceVerdicts(); - - foreach (var candidate in candidates) + foreach (var candidate in CandidateTypeNames(mangledName, namespaces)) { if (TypeExists(candidate)) return true; } + // The framework set is complete — a process constant indexed once — so there is no second + // chance to give: no later load can add a framework type it lacks (#2250). return false; } @@ -269,8 +226,8 @@ internal bool ResolvesToClrType(string mangledName, IEnumerable? importe /// import is NOT counted as used) or does not resolve at all (#1429). /// /// Kept separate from , which answers the different - /// question the SPY0495 refusal asks — "does it resolve anywhere". Both share the type cache and - /// the second-chance framework load, so calling both costs one resolution's worth of reflection. + /// question the SPY0495 refusal asks — "does it resolve anywhere". Both read the compilation's + /// visibility, so calling both costs one resolution's worth of reflection. /// internal string? RequiredImportNamespace( string mangledName, IEnumerable? importedNamespaces) @@ -279,23 +236,6 @@ internal bool ResolvesToClrType(string mangledName, IEnumerable? importe if (namespaces == null || namespaces.Count == 0) return null; - var found = FindRequiredImportNamespace(mangledName, namespaces); - if (found != null) - return found; - - // Second chance: the required type may live in a shared-framework assembly nothing has - // loaded yet. Pull in the imports' assemblies and retry (mirrors ResolvesToClrType). - var loadedSomething = false; - foreach (var ns in namespaces) - { - if (!string.IsNullOrEmpty(ns)) - loadedSomething |= EnsureFrameworkAssembliesLoaded(ns); - } - - if (!loadedSomething) - return null; - - InvalidateAbsenceVerdicts(); return FindRequiredImportNamespace(mangledName, namespaces); } @@ -330,21 +270,16 @@ private bool NamespaceResolves(string namespaceName, string mangledName) private bool TypeExists(string fullName) { - if (s_typePresentCache.ContainsKey(fullName)) - return true; if (_typeAbsentThisCompilation.Contains(fullName)) return false; - if (FindType(fullName) != null) - { - s_typePresentCache.TryAdd(fullName, 0); + // The compilation's visibility caches the answer per name (nulls too) and replaces that + // cache when its own set grows (#2250). + if (_visibility.ResolveType(fullName) != null) return true; - } // Last, because it is the most expensive and the least often needed: the project's own - // references. Not added to the process-wide presence cache — that verdict is a function of - // THIS project's references, and a different project in the same process has different - // ones (#1492). + // reference closure, read as metadata (#1492). if (ReferencedAssembliesDeclare(fullName)) return true; @@ -353,132 +288,8 @@ private bool TypeExists(string fullName) } /// - /// Forgets this compilation's absence verdicts. Called when a load has just widened the set of - /// types that could exist, so the retry is a real second look rather than a replay of the - /// answers that motivated it. + /// Forgets this compilation's absence verdicts. Called when the reference closure changes, so a + /// verdict reached against a smaller world is not replayed. /// private void InvalidateAbsenceVerdicts() => _typeAbsentThisCompilation.Clear(); - - private static Type? FindType(string fullName) - { - var direct = Type.GetType(fullName, throwOnError: false); - if (direct != null) - return direct; - - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - var found = SafeGetType(assembly, fullName); - if (found != null) - return found; - } - - return null; - } - - private static Type? SafeGetType(Assembly assembly, string fullName) - { - try - { - return assembly.GetType(fullName, throwOnError: false); - } - catch (Exception ex) when (ex is FileNotFoundException or TypeLoadException or BadImageFormatException) - { - // An assembly whose own references cannot be resolved cannot contribute a type. - return null; - } - } - - /// - /// The simple names of , skipping any assembly that cannot describe - /// itself. - /// - /// An assembly in the process can fail to answer — a - /// transient assembly a test emitted whose file has since been replaced throws - /// ("Index not found") from the runtime's locale read. That - /// exception escaped into the validator and surfaced as an internal compiler error on programs - /// with nothing wrong with them, nondeterministically, depending on what else the process had - /// loaded (#1840). One unreadable assembly is not a reason to fail a compilation: it only means - /// that assembly's name cannot be compared against, so it is skipped and every other assembly is - /// still collected. - /// - internal static HashSet LoadedSimpleNames(IEnumerable assemblies) - { - var names = new HashSet(StringComparer.Ordinal); - - foreach (var assembly in assemblies) - { - string? name; - try - { - name = assembly.GetName().Name; - } - catch (Exception ex) when (ex is BadImageFormatException or FileNotFoundException - or ReflectionTypeLoadException or NotSupportedException) - { - continue; - } - - if (name != null) - names.Add(name); - } - - return names; - } - - /// - /// Loads the trusted-platform assemblies whose simple name matches - /// or one of its parents. Returns true when at least one new assembly was loaded. - /// - private static bool EnsureFrameworkAssembliesLoaded(string namespaceName) - { - if (namespaceName.Length == 0) - return false; - if (!_probedNamespaces.TryAdd(namespaceName, 0)) - return false; - - if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is not string tpaString) - return false; - - // .NET does not distribute types along namespace lines (System.Net.Sockets.AddressFamily - // lives in System.Net.Primitives.dll), so parent-namespace assemblies are matched too. - var prefixes = new List { namespaceName + ".", namespaceName }; - var parts = namespaceName.Split('.'); - for (var i = parts.Length - 1; i >= 2; i--) - prefixes.Add(string.Join(".", parts, 0, i) + "."); - - var loadedNames = LoadedSimpleNames(AppDomain.CurrentDomain.GetAssemblies()); - - var loadedAny = false; - foreach (var path in tpaString.Split(Path.PathSeparator)) - { - var fileName = Path.GetFileNameWithoutExtension(path); - if (string.IsNullOrEmpty(fileName) || loadedNames.Contains(fileName)) - continue; - - var matches = false; - foreach (var prefix in prefixes) - { - if (fileName.StartsWith(prefix, StringComparison.Ordinal) - || string.Equals(fileName, prefix.TrimEnd('.'), StringComparison.Ordinal)) - { - matches = true; - break; - } - } - if (!matches) - continue; - - try - { - Assembly.LoadFrom(path); - loadedAny = true; - } - catch (Exception ex) when (ex is IOException or BadImageFormatException or UnauthorizedAccessException) - { - // An assembly that will not load cannot contribute a type. - } - } - - return loadedAny; - } } diff --git a/src/Sharpy.Compiler/Discovery/ClrExtensionMethodResolver.cs b/src/Sharpy.Compiler/Discovery/ClrExtensionMethodResolver.cs index 21758a243..d1bfc3702 100644 --- a/src/Sharpy.Compiler/Discovery/ClrExtensionMethodResolver.cs +++ b/src/Sharpy.Compiler/Discovery/ClrExtensionMethodResolver.cs @@ -691,7 +691,7 @@ private static bool TryBindThisParameter(Type thisParameter, Type receiverType, private static Type[] Ancestors(Type type) { - return _ancestorCache.GetOrAdd(type, static t => + return AssemblyVisibility.GetOrAddIfProcessStable(_ancestorCache, type, type, static t => { var all = new List(); for (var current = t; current != null; current = current.BaseType) diff --git a/src/Sharpy.Compiler/Discovery/ClrTypeBridge.cs b/src/Sharpy.Compiler/Discovery/ClrTypeBridge.cs index 1d9c77688..c1a0808c3 100644 --- a/src/Sharpy.Compiler/Discovery/ClrTypeBridge.cs +++ b/src/Sharpy.Compiler/Discovery/ClrTypeBridge.cs @@ -426,7 +426,7 @@ internal bool MapsToSharpyBuiltin(Type genericDefinition) if (genericDefinition is not { IsGenericTypeDefinition: true }) return false; - return _mapsToSharpyBuiltinCache.GetOrAdd(genericDefinition, definition => + return AssemblyVisibility.GetOrAddIfProcessStable(_mapsToSharpyBuiltinCache, genericDefinition, genericDefinition, definition => { Type probe; try diff --git a/src/Sharpy.Compiler/Discovery/ClrTypeHelper.cs b/src/Sharpy.Compiler/Discovery/ClrTypeHelper.cs index f1924cede..b8817406c 100644 --- a/src/Sharpy.Compiler/Discovery/ClrTypeHelper.cs +++ b/src/Sharpy.Compiler/Discovery/ClrTypeHelper.cs @@ -61,7 +61,7 @@ internal static class ClrTypeHelper } } - _clrMethodNameCache[(clrType, memberName)] = resolved; + AssemblyVisibility.SetIfProcessStable(_clrMethodNameCache, (clrType, memberName), clrType, resolved); return resolved; } @@ -89,7 +89,7 @@ internal static ImmutableArray ResolveGenericInstanceMethods(Type cl && NameMangler.ToSharpyName(m.Name, ReverseNameContext.Method) == memberName) .ToImmutableArray(); - _genericInstanceMethodCache[(clrType, memberName)] = matches; + AssemblyVisibility.SetIfProcessStable(_genericInstanceMethodCache, (clrType, memberName), clrType, matches); return matches; } @@ -115,7 +115,7 @@ internal static ImmutableArray ResolveGenericInstanceMethods(Type cl /// internal static IReadOnlySet? GetMemberNameSurface(Type clrType) { - return _memberNameSurfaceCache.GetOrAdd(clrType, static t => + return AssemblyVisibility.GetOrAddIfProcessStable(_memberNameSurfaceCache, clrType, clrType, static t => { var names = new HashSet(StringComparer.Ordinal); try @@ -210,7 +210,7 @@ private static void CollectMemberNames(Type type, HashSet names) /// internal static IReadOnlySet GetExtensionMethodNames(Assembly assembly) { - return _extensionMethodNameCache.GetOrAdd(assembly, static asm => + return AssemblyVisibility.GetOrAddIfProcessStable(_extensionMethodNameCache, assembly, static asm => { var names = new HashSet(StringComparer.Ordinal); foreach (var method in EnumerateExtensionMethods(asm)) @@ -238,7 +238,7 @@ private static readonly ConcurrentDictionary internal static IReadOnlyDictionary GetExtensionMethodsByVerbatimName(Assembly assembly) { - return _extensionMethodsByVerbatimNameCache.GetOrAdd(assembly, static asm => + return AssemblyVisibility.GetOrAddIfProcessStable(_extensionMethodsByVerbatimNameCache, assembly, static asm => EnumerateExtensionMethods(asm) .GroupBy(m => m.Name, StringComparer.Ordinal) .ToFrozenDictionary(g => g.Key, g => g.ToArray(), StringComparer.Ordinal)); @@ -315,7 +315,7 @@ private static IEnumerable EnumerateExtensionMethods(Assembly assemb } } - _clrPropertyNameCache[(clrType, memberName)] = resolved; + AssemblyVisibility.SetIfProcessStable(_clrPropertyNameCache, (clrType, memberName), clrType, resolved); return resolved; } @@ -325,13 +325,13 @@ private static IEnumerable EnumerateExtensionMethods(Assembly assemb /// a[1, 2] can be spread into separate element-access arguments (#956). Used by the /// TypeChecker to materialize IndexAccessLowering.ParamsSpread; the emitter never reflects. /// - internal static bool HasParamsIndexer(SemanticType? type) + internal static bool HasParamsIndexer(AssemblyVisibility visibility, SemanticType? type) { var clrType = type switch { UserDefinedType udt => udt.Symbol?.ClrType, BuiltinType bt => bt.ClrType, - GenericType gt => TryConstructClosedGeneric(gt, t => t.ClrType ?? typeof(object)), + GenericType gt => TryConstructClosedGeneric(visibility, gt, t => t.ClrType ?? typeof(object)), _ => null }; @@ -353,7 +353,8 @@ internal static bool HasParamsIndexer(SemanticType? type) } } - _paramsIndexerCache.TryAdd(clrType, result); + if (AssemblyVisibility.IsProcessStable(clrType)) + _paramsIndexerCache.TryAdd(clrType, result); return result; } @@ -398,78 +399,18 @@ internal static bool HasParamsIndexer(SemanticType? type) } } - _overridableMemberCache[key] = result; + AssemblyVisibility.SetIfProcessStable(_overridableMemberCache, key, clrType, result); return result; } - // Caches a stored CLR type name -> the resolved CLR type, so every channel that records a - // CLR identity as text scans loaded assemblies at most once per name. Null is cached too: - // an unresolvable name stays unresolvable. - private static readonly ConcurrentDictionary _originNameDefinitionCache = new(); - - /// - /// Resolves a CLR type by the FullName a cache channel recorded — Type.GetType first, - /// then a loaded-assembly scan, null-cached per name. The SHARED origin resolver for every - /// serializer channel that stores a CLR identity as text (the #1538 builtin channel, the - /// #1534 UnmappedClrType channel) and for 's - /// generic-definition lookup, which layers its arity gates on top. - /// - internal static Type? ResolveClrTypeByStoredName(string fullName) - => _originNameDefinitionCache.GetOrAdd(fullName, static name => - { - var direct = Type.GetType(name); - if (direct != null) - return direct; - - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - var found = assembly.GetType(name); - if (found != null) - return found; - } - return null; - }); - - // Caches a bare Sharpy display name + arity -> the resolved CLR generic definition (or null), - // so the last-resort scan below runs at most once per spelling instead of on every negative - // protocol probe. - private static readonly ConcurrentDictionary _displayNameDefinitionCache = new(); - /// - /// Last-resort resolution of a CLR generic definition from a bare Sharpy DISPLAY name (no - /// , no symbol) — the shape a return type takes - /// after losing its identity through the overload-index → builtin-substitution chain (#1517). - /// Probes System.Collections.Generic directly, then scans loaded assemblies for an arity - /// match on the StripArity'd simple name. Cached per name+arity, nulls included. + /// The CLR type a denotes, closed over its arguments' identities (the + /// open definition when an argument has none). A definition recorded only by name + /// () resolves against — + /// the compilation's, never the process's (#2250). /// - internal static Type? ResolveGenericDefinitionByDisplayName(string displayName, int arity) - => _displayNameDefinitionCache.GetOrAdd($"{displayName}`{arity}", _ => - { - var candidateName = displayName.Contains('`', StringComparison.Ordinal) - ? displayName - : displayName + "`" + arity; - - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - try - { - var found = assembly.GetType("System.Collections.Generic." + candidateName) - ?? assembly.GetTypes().FirstOrDefault(t => - t.IsGenericTypeDefinition - && Shared.ClrNameHelper.StripArity(t.Name) == displayName - && t.GetGenericArguments().Length == arity); - if (found != null) - return found; - } - catch (ReflectionTypeLoadException) - { - // A partially-loadable assembly cannot veto the scan — skip it. - } - } - return null; - }); - - internal static Type? TryConstructClosedGeneric(GenericType generic, Func resolveClrType) + internal static Type? TryConstructClosedGeneric( + AssemblyVisibility visibility, GenericType generic, Func resolveClrType) { // Provenance has two channels and a GenericType may carry either one. TypeResolver's // ANNOTATION path sets GenericDefinition from the resolved symbol; the discovery path that @@ -480,7 +421,7 @@ internal static bool HasParamsIndexer(SemanticType? type) // when it came from an expression — so CLR operator resolution accepted `x | y` and refused // `d.keys() | e.keys()` (#1496). Both channels answer the same question, so both are read // here, at the single funnel every CLR operator/member resolution already goes through. - var openDef = generic.GenericDefinition?.ClrType ?? ResolveOriginDefinition(generic); + var openDef = generic.GenericDefinition?.ClrType ?? ResolveOriginDefinition(visibility, generic); if (openDef == null || !openDef.IsGenericTypeDefinition) return openDef; @@ -509,12 +450,14 @@ internal static bool HasParamsIndexer(SemanticType? type) /// longer resolves. Arity is checked here so a name that resolves to a differently-shaped /// definition is rejected rather than blowing up inside MakeGenericType. /// - internal static Type? ResolveOriginDefinition(GenericType generic) + internal static Type? ResolveOriginDefinition(AssemblyVisibility visibility, GenericType generic) { if (generic.ClrOriginTypeName is not { Length: > 0 } originName) return null; - var resolved = ResolveClrTypeByStoredName(originName); + // Resolved against the compilation's visibility — the one origin resolver every channel that + // stores a CLR identity as text shares, cached per name there (#1538, #2250). + var resolved = visibility.ResolveType(originName); if (resolved is not { IsGenericTypeDefinition: true }) return null; @@ -604,8 +547,10 @@ internal static bool ClrOriginIsSatisfiedBy( /// /// private static Type? FindGenericDefinitionInClosure(Type type, string definitionFullName) - => _originDefinitionCache.GetOrAdd( + => AssemblyVisibility.GetOrAddIfProcessStable( + _originDefinitionCache, (type, definitionFullName), + type, key => FindGenericDefinitionInClosureUncached(key.Item1, key.Item2)); private static Type? FindGenericDefinitionInClosureUncached(Type type, string definitionFullName) diff --git a/src/Sharpy.Compiler/Discovery/ReferenceLoadContext.cs b/src/Sharpy.Compiler/Discovery/ReferenceLoadContext.cs new file mode 100644 index 000000000..22497254c --- /dev/null +++ b/src/Sharpy.Compiler/Discovery/ReferenceLoadContext.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Runtime.Loader; +using Sharpy.Compiler.Shared; + +namespace Sharpy.Compiler.Discovery; + +/// +/// A load context for a compilation's references, keyed by its reference set (#2250, P54 Design +/// Decision 2, R-HI's "isolated registry per compilation"). A ModuleRegistry loads each +/// non-framework reference with into a context +/// obtained here — never into the process's default context with Assembly.LoadFrom — so a +/// same-named assembly another compilation loaded from another path is that compilation's own copy, +/// invisible here, and "already loaded from another path" (the old SPY0306 arm) cannot occur. +/// +/// Keyed by the reference set. A context is identified by its reference file's identity +/// (full path, length, last-write time), the identities of the references the registry loaded +/// BEFORE it, in order, and the registry's module paths at that time — the same identity convention +/// as AssemblyCompiler.s_referenceCache. Its contents are a pure function of that key, so +/// registries with identical inputs share one context (observably one context per compilation) and +/// any different input — another path, a rebuilt file — gets a different one. Sharing is what keeps +/// a host that analyses many documents (the LSP, the test suites) from loading one copy of the +/// standard library per analysis: loader memory is native, invisible to GC pressure, and a context +/// per registry ran Lsp.Tests out of memory. The key is applied incrementally, one reference at a +/// time, so loading is never deferred and no refusal moves. +/// +/// One identity for the framework. answers a dependency from the +/// references loaded before this one (part of the key) or this context's own loads, and otherwise +/// returns null — which defers to the default context, so Sharpy.Core and the framework keep the ONE +/// identity the compiler was built against. A dependency the default context cannot find is searched +/// for, by , in the reference's own directory and the module +/// paths in the key — the directories ModuleRegistry.ResolveAssemblyPath searches, no wider; one +/// that resolves nowhere stays #2182's cell. +/// +/// Collectible, weakly shared. Registries hold their contexts strongly; the process-wide +/// share holds them weakly, so a context unloads once no registry uses it. A lookup that finds a dead +/// entry replaces it, and every creation prunes dead entries, so the share is bounded by the contexts +/// alive. The process-wide Type-keyed caches cache only process-stable types +/// (, Design Decision 3), so nothing else +/// pins a context. +/// +[ThreadSafe] +internal sealed class ReferenceLoadContext : AssemblyLoadContext +{ + /// + /// The share: reference-set key -> the context loaded for it, held weakly. Key-pure — a context's + /// contents are a function of the files and directories its key names. + /// + private static readonly ConcurrentDictionary> s_shared = + new(StringComparer.Ordinal); + + private static readonly object s_createGate = new(); + + private readonly IReadOnlyList _prior; + private readonly IReadOnlyList _searchDirectories; + private readonly ConcurrentDictionary _own = new(StringComparer.Ordinal); + + private ReferenceLoadContext(string fullPath, IReadOnlyList prior, IReadOnlyList modulePaths) + : base("sharpy-references-" + Path.GetFileNameWithoutExtension(fullPath) + "-" + Guid.NewGuid().ToString("N"), + isCollectible: true) + { + _prior = prior; + var directories = new List(); + if (Path.GetDirectoryName(fullPath) is { Length: > 0 } directory) + directories.Add(directory); + directories.AddRange(modulePaths); + _searchDirectories = directories; + Resolving += ResolveFromSearchDirectories; + + Reference = LoadFromAssemblyPath(fullPath); + if (AssemblyVisibility.SafeSimpleName(Reference) is { } simple) + _own.TryAdd(simple, Reference); + } + + /// The reference this context was created for. + public Assembly Reference { get; } + + /// + /// The context for the reference at , given the references the registry + /// loaded before it (in order) and its module paths: the shared one when the key's context is + /// alive, else a new one. The compiler's one construction of a reference context. + /// + public static ReferenceLoadContext GetOrLoad( + string fullPath, IReadOnlyList prior, IReadOnlyList modulePaths) + { + var key = KeyOf(fullPath, prior, modulePaths); + lock (s_createGate) + { + if (s_shared.TryGetValue(key, out var weak) && weak.TryGetTarget(out var alive)) + return alive; + + // A dead entry (or none): prune every dead entry, then load and publish. + foreach (var entry in s_shared) + { + if (!entry.Value.TryGetTarget(out _)) + s_shared.TryRemove(entry.Key, out _); + } + + var created = new ReferenceLoadContext(fullPath, prior, modulePaths); + s_shared[key] = new WeakReference(created); + return created; + } + } + + /// The share's keys (live and not yet pruned), for the retention probe. + internal static IReadOnlyCollection SharedKeys => s_shared.Keys.ToList(); + + private static string KeyOf(string fullPath, IReadOnlyList prior, IReadOnlyList modulePaths) + => FileIdentity(fullPath) + + "|" + string.Join(";", prior.Select(a => FileIdentity(a.Location))) + + "|" + string.Join(";", modulePaths); + + private static string FileIdentity(string path) + { + var info = new FileInfo(path); + return info.Exists + ? info.FullName + ":" + info.Length.ToString(CultureInfo.InvariantCulture) + ":" + + info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture) + : path; + } + + /// + /// A dependency named by a reference: a reference loaded before this one, or this context's own + /// load, by simple name; else null — the default context answers (one framework / Sharpy.Core + /// identity). + /// + protected override Assembly? Load(AssemblyName assemblyName) + { + if (assemblyName.Name is not { } simple) + return null; + if (_own.TryGetValue(simple, out var own)) + return own; + foreach (var prior in _prior) + { + if (string.Equals(AssemblyVisibility.SafeSimpleName(prior), simple, StringComparison.Ordinal)) + return prior; + } + return null; + } + + private Assembly? ResolveFromSearchDirectories(AssemblyLoadContext context, AssemblyName assemblyName) + { + if (assemblyName.Name is not { Length: > 0 } simple) + return null; + + foreach (var directory in _searchDirectories) + { + var candidate = Path.Combine(directory, simple + ".dll"); + if (!File.Exists(candidate)) + continue; + try + { + var assembly = LoadFromAssemblyPath(Path.GetFullPath(candidate)); + _own.TryAdd(simple, assembly); + return assembly; + } + catch (Exception ex) when (ex is IOException or BadImageFormatException or UnauthorizedAccessException) + { + // A candidate that will not load is not the dependency; keep looking. + } + } + return null; + } +} diff --git a/src/Sharpy.Compiler/Project/IncrementalCompilationCache.cs b/src/Sharpy.Compiler/Project/IncrementalCompilationCache.cs index 371f28ccf..4098673d8 100644 --- a/src/Sharpy.Compiler/Project/IncrementalCompilationCache.cs +++ b/src/Sharpy.Compiler/Project/IncrementalCompilationCache.cs @@ -673,8 +673,13 @@ public void CacheGeneratorOutput( /// /// The path to the source file. /// The registry to populate with restored symbols. + /// + /// The restoring compilation's CLR visibility — a CLR identity stored as text resolves against it, + /// never against the process (#2250). The framework set alone when null. + /// /// True if symbols were restored, false if no valid cache. - public bool RestoreSymbols(string filePath, Dictionary symbolRegistry, SemanticBinding? binding = null) + public bool RestoreSymbols(string filePath, Dictionary symbolRegistry, SemanticBinding? binding = null, + Discovery.AssemblyVisibility? visibility = null) { var entry = GetFileCache(filePath); if (entry == null) @@ -684,7 +689,7 @@ public bool RestoreSymbols(string filePath, Dictionary symbolReg foreach (var cachedSymbol in entry.Symbols) { - var symbol = SymbolSerializer.Deserialize(cachedSymbol, symbolRegistry, binding: binding); + var symbol = SymbolSerializer.Deserialize(cachedSymbol, symbolRegistry, binding: binding, visibility: visibility); symbolRegistry[cachedSymbol.Id] = symbol; } diff --git a/src/Sharpy.Compiler/Project/ProjectCompiler.Initialization.cs b/src/Sharpy.Compiler/Project/ProjectCompiler.Initialization.cs index f0bab9f10..456469de5 100644 --- a/src/Sharpy.Compiler/Project/ProjectCompiler.Initialization.cs +++ b/src/Sharpy.Compiler/Project/ProjectCompiler.Initialization.cs @@ -12,7 +12,7 @@ internal partial class ProjectCompiler /// private void InitializeSharedState() { - var builtinRegistry = _sharedBuiltinRegistry ?? new BuiltinRegistry(_logger); + var builtinRegistry = _sharedBuiltinRegistry ?? new BuiltinRegistry(_logger, _moduleRegistry?.Visibility); _symbolTableBacking = new SymbolTable(builtinRegistry); _semanticInfoBacking = new SemanticInfo(); _semanticInfoBacking.SetSymbolTable(_symbolTableBacking); @@ -91,7 +91,7 @@ private void RestoreCachedSymbols() servedUnit.CachedReferencedClrTypeNames = new HashSet( cachedFile.ReferencedClrTypeNames ?? new List(), StringComparer.Ordinal); } - if (_incrementalCache.RestoreSymbols(filePath, _restoredSymbols, semanticBinding)) + if (_incrementalCache.RestoreSymbols(filePath, _restoredSymbols, semanticBinding, _moduleRegistry?.Visibility)) { restoredByFile.Add((filePath, _restoredSymbols .Where(kv => !keysBefore.Contains(kv.Key)) @@ -271,7 +271,8 @@ void Index(TypeSymbol type, string? declaringFile) return registered; } - return Discovery.ClrTypeHelper.ResolveClrTypeByStoredName(clrName) is { } clrType + var visibility = _moduleRegistry?.Visibility ?? Discovery.AssemblyVisibility.FrameworkOnly; + return visibility.ResolveType(clrName) is { } clrType ? _moduleRegistry?.CreateTypeSymbolFromClrType(clrType) : null; } diff --git a/src/Sharpy.Compiler/Project/RestoredTypeRelinker.cs b/src/Sharpy.Compiler/Project/RestoredTypeRelinker.cs index 796956d99..e22bd42b1 100644 --- a/src/Sharpy.Compiler/Project/RestoredTypeRelinker.cs +++ b/src/Sharpy.Compiler/Project/RestoredTypeRelinker.cs @@ -171,7 +171,7 @@ private void RelinkFunction(FunctionSymbol function) { var relinked = Relink(function.Parameters[i].Type); if (!ReferenceEquals(relinked, function.Parameters[i].Type)) - function.Parameters[i] = function.Parameters[i] with { Type = relinked }; + function.ReplaceParameter(i, function.Parameters[i] with { Type = relinked }); } } } diff --git a/src/Sharpy.Compiler/Project/SymbolSerializer.cs b/src/Sharpy.Compiler/Project/SymbolSerializer.cs index b8ec32b5e..72b350ac3 100644 --- a/src/Sharpy.Compiler/Project/SymbolSerializer.cs +++ b/src/Sharpy.Compiler/Project/SymbolSerializer.cs @@ -49,13 +49,19 @@ public static CachedSymbol Serialize(Symbol symbol, string filePath, SemanticBin /// The cached symbol to deserialize. /// Registry of already-deserialized symbols for resolving cross-references. /// Optional function to resolve type IDs to SemanticTypes. + /// + /// The CLR types the restoring compilation may see: a CLR identity stored as text resolves against + /// it, never against the process's loaded set (#2250). The framework set alone when null. + /// public static Symbol Deserialize( CachedSymbol cached, Dictionary symbolRegistry, Func? typeResolver = null, - SemanticBinding? binding = null) + SemanticBinding? binding = null, + Discovery.AssemblyVisibility? visibility = null) { - typeResolver ??= ResolveTypeFromId; + var effectiveVisibility = visibility ?? Discovery.AssemblyVisibility.FrameworkOnly; + typeResolver ??= typeId => ResolveTypeFromId(typeId, effectiveVisibility); return cached.Kind switch { @@ -1058,9 +1064,9 @@ private static ParameterSymbol DeserializeParameter( /// Resolves a type ID back to a SemanticType. /// Delegates to for encode/decode logic. /// - private static SemanticType ResolveTypeFromId(string typeId) + private static SemanticType ResolveTypeFromId(string typeId, Discovery.AssemblyVisibility visibility) { - return TypeCodecRegistry.Deserialize(typeId); + return TypeCodecRegistry.Deserialize(typeId, visibility); } @@ -1142,7 +1148,7 @@ private static int IndexAtDepthZero(string s, char c) /// /// Parses a comma-separated list of type IDs, handling nested brackets. /// - private static List ParseTypeArguments(string argsStr) + private static List ParseTypeArguments(string argsStr, Discovery.AssemblyVisibility visibility) { var types = new List(); if (string.IsNullOrEmpty(argsStr)) @@ -1161,7 +1167,7 @@ private static List ParseTypeArguments(string argsStr) else if (c == ',' && depth == 0) { var typeId = argsStr[start..i].Trim(); - types.Add(ResolveTypeFromId(typeId)); + types.Add(ResolveTypeFromId(typeId, visibility)); start = i + 1; } } @@ -1170,7 +1176,7 @@ private static List ParseTypeArguments(string argsStr) if (start < argsStr.Length) { var typeId = argsStr[start..].Trim(); - types.Add(ResolveTypeFromId(typeId)); + types.Add(ResolveTypeFromId(typeId, visibility)); } return types; @@ -1451,7 +1457,7 @@ private static void ResolveTypeReferences( private static class TypeCodecRegistry { private delegate string Encoder(SemanticType type); - private delegate SemanticType Decoder(string value); + private delegate SemanticType Decoder(string value, Discovery.AssemblyVisibility visibility); /// /// Maps concrete SemanticType CLR types to their (prefix, encoder) pair. @@ -1482,7 +1488,7 @@ private static void RegisterAll() { Register("builtin", bt => bt.ClrType is { } t ? $"{bt.Name}@{t.FullName}" : bt.Name, - value => ResolveBuiltinType(value)); + (value, visibility) => ResolveBuiltinType(value, visibility)); // Format: name[args], with `name@ClrOriginTypeName[args]` when the type was mapped from CLR // metadata (#1260). The origin travels because a warm incremental build reads a cached @@ -1507,7 +1513,7 @@ private static void RegisterAll() gt => (ClrOriginOf(gt) ?? SharpyOriginOf(gt)) is { Length: > 0 } origin ? $"{gt.Name}@{origin}[{string.Join(",", gt.TypeArguments.Select(Serialize))}]" : $"{gt.Name}[{string.Join(",", gt.TypeArguments.Select(Serialize))}]", - value => + (value, visibility) => { var bracketIndex = value.IndexOf('[', StringComparison.Ordinal); if (bracketIndex < 0) @@ -1528,7 +1534,7 @@ private static void RegisterAll() Name = name, ClrOriginTypeName = IsSharpyOrigin(origin) ? null : origin, CacheOrigin = IsSharpyOrigin(origin) ? origin : null, - TypeArguments = ParseTypeArguments(argsStr) + TypeArguments = ParseTypeArguments(argsStr, visibility) }; }); @@ -1542,7 +1548,7 @@ private static void RegisterAll() // occur in a type name, so the first one splits the payload. Register("user", udt => CachedTypeOrigin.Of(udt) is { } origin ? $"{udt.Name}@{origin}" : udt.Name, - value => + (value, visibility) => { var at = value.IndexOf('@', StringComparison.Ordinal); return at < 0 @@ -1552,23 +1558,23 @@ private static void RegisterAll() Register("unmappedclr", uct => uct.ClrTypeName, - value => new UnmappedClrType + (value, visibility) => new UnmappedClrType { ClrTypeName = value, // The carried identity is restored when it resolves (shared origin resolver, // null-cached); an unresolvable name keeps today's graceful null (#1534). ClrType = value.Length > 0 - ? Discovery.ClrTypeHelper.ResolveClrTypeByStoredName(value) + ? visibility.ResolveType(value) : null }); Register("nullable", nt => Serialize(nt.UnderlyingType), - value => new NullableType { UnderlyingType = Deserialize(value) }); + (value, visibility) => new NullableType { UnderlyingType = Deserialize(value, visibility) }); Register("optional", ot => Serialize(ot.UnderlyingType), - value => new OptionalType { UnderlyingType = Deserialize(value) }); + (value, visibility) => new OptionalType { UnderlyingType = Deserialize(value, visibility) }); // FunctionType format: (paramTypes|V|O)->returnType // V = VariadicParameterIndex as integer or "-" for null @@ -1580,7 +1586,7 @@ private static void RegisterAll() var o = ft.OptionalParameterCount.ToString(System.Globalization.CultureInfo.InvariantCulture); return $"({string.Join(",", ft.ParameterTypes.Select(Serialize))}|{v}|{o})->{Serialize(ft.ReturnType)}"; }, - value => + (value, visibility) => { // The arrow that follows the parameter list's own closing paren — a function-typed // parameter carries an arrow of its own, and the first one in the payload is ITS @@ -1639,11 +1645,11 @@ private static void RegisterAll() var paramTypes = string.IsNullOrEmpty(paramsStr) ? new List() - : ParseTypeArguments(paramsStr); + : ParseTypeArguments(paramsStr, visibility); return new FunctionType { ParameterTypes = paramTypes, - ReturnType = Deserialize(returnStr), + ReturnType = Deserialize(returnStr, visibility), VariadicParameterIndex = variadicIndex, OptionalParameterCount = optionalCount }; @@ -1651,26 +1657,26 @@ private static void RegisterAll() Register("tuple", tt => $"[{string.Join(",", tt.ElementTypes.Select(Serialize))}]", - value => + (value, visibility) => { // Exactly the outer pair: a last element that is itself bracketed (`list[P]`) ends // the payload in `]]`, and trimming every `]` cut the element's own bracket and then // its last character (`…lib.spy` → `…lib.sp`, an origin that relinks to nothing). var inner = value.Length >= 2 && value[0] == '[' && value[^1] == ']' ? value[1..^1] : value; - return new TupleType { ElementTypes = ParseTypeArguments(inner) }; + return new TupleType { ElementTypes = ParseTypeArguments(inner, visibility) }; }); Register("void", _ => "", - _ => SemanticType.Void); + (_, _) => SemanticType.Void); Register("unknown", _ => "", - _ => SemanticType.Unknown); + (_, _) => SemanticType.Unknown); Register("module", mt => mt.Symbol.Name, - value => + (value, visibility) => { var placeholderSymbol = new ModuleSymbol { Name = value, Kind = SymbolKind.Module }; return new ModuleType { Symbol = placeholderSymbol }; @@ -1678,7 +1684,7 @@ private static void RegisterAll() Register("typeparam", tpt => tpt.Name, - value => new TypeParameterType { Name = value }); + (value, visibility) => new TypeParameterType { Name = value }); // Format: ok!err, split at the first `!` outside brackets — an ok type holding a Result of its // own (`(int !ValueError) !KeyError` has ok `tuple[int !ValueError]`) carries a `!` first, and @@ -1690,7 +1696,7 @@ private static void RegisterAll() rt => rt.OkType is ResultType ? $"({Serialize(rt.OkType)})!{Serialize(rt.ErrorType)}" : $"{Serialize(rt.OkType)}!{Serialize(rt.ErrorType)}", - value => + (value, visibility) => { var bangIndex = IndexAtDepthZero(value, '!'); if (bangIndex < 0) @@ -1698,19 +1704,19 @@ private static void RegisterAll() var okStr = value[..bangIndex]; if (okStr.Length >= 2 && okStr[0] == '(' && MatchingClose(okStr, 0) == okStr.Length - 1) okStr = okStr[1..^1]; - return new ResultType { OkType = Deserialize(okStr), ErrorType = Deserialize(value[(bangIndex + 1)..]) }; + return new ResultType { OkType = Deserialize(okStr, visibility), ErrorType = Deserialize(value[(bangIndex + 1)..], visibility) }; }); // GenericFunctionType: serialize as the underlying function with type args marker Register("gfunc", gft => $"{gft.FunctionSymbol.Name}[{string.Join(",", gft.TypeArguments.Select(Serialize))}]", - value => + (value, visibility) => { var bracketIndex = value.IndexOf('[', StringComparison.Ordinal); if (bracketIndex < 0) return SemanticType.Unknown; var argsStr = value[(bracketIndex + 1)..^1]; - var typeArgs = ParseTypeArguments(argsStr); + var typeArgs = ParseTypeArguments(argsStr, visibility); // Return as GenericType -- full GenericFunctionType restoration // requires the FunctionSymbol which is not available during deserialization return new GenericType { Name = value[..bracketIndex], TypeArguments = typeArgs }; @@ -1719,30 +1725,30 @@ private static void RegisterAll() // UnionType: placeholder for v0.2.x tagged unions Register("union", ut => $"{ut.Name}[{string.Join(",", ut.CaseTypes.Select(Serialize))}]", - value => + (value, visibility) => { var bracketIndex = value.IndexOf('[', StringComparison.Ordinal); if (bracketIndex < 0) return new UnionType { Name = value, CaseTypes = new List() }; var name = value[..bracketIndex]; var argsStr = value[(bracketIndex + 1)..^1]; - return new UnionType { Name = name, CaseTypes = ParseTypeArguments(argsStr) }; + return new UnionType { Name = name, CaseTypes = ParseTypeArguments(argsStr, visibility) }; }); // TaskType: async task wrapper Register("task", tt => tt.ResultType == null ? "" : Serialize(tt.ResultType), - value => string.IsNullOrEmpty(value) + (value, visibility) => string.IsNullOrEmpty(value) ? new TaskType { ResultType = null } - : new TaskType { ResultType = Deserialize(value) }); + : new TaskType { ResultType = Deserialize(value, visibility) }); Register("literalstring", _ => "", - _ => LiteralStringType.Instance); + (_, _) => LiteralStringType.Instance); Register("self", st => st.DeclaringType?.Name ?? "", - value => string.IsNullOrEmpty(value) + (value, visibility) => string.IsNullOrEmpty(value) ? new SelfType() : new SelfType { DeclaringType = new TypeSymbol { Name = value, Kind = SymbolKind.Type } }); } @@ -1756,7 +1762,7 @@ public static string Serialize(SemanticType type) $"Unhandled SemanticType in SerializeType: {type.GetType().Name}"); } - public static SemanticType Deserialize(string typeId) + public static SemanticType Deserialize(string typeId, Discovery.AssemblyVisibility visibility) { if (string.IsNullOrEmpty(typeId)) return SemanticType.Unknown; @@ -1769,7 +1775,7 @@ public static SemanticType Deserialize(string typeId) var value = typeId[(colonIndex + 1)..]; if (s_decoders.TryGetValue(prefix, out var decode)) - return decode(value); + return decode(value, visibility); // Unknown prefix -- graceful degradation with older cache formats return SemanticType.Unknown; @@ -1820,7 +1826,7 @@ public static SemanticType Deserialize(string typeId) /// singleton). BuiltinTypeSerializerTotalityTests asserts this over the whole /// reflection-enumerated census. /// - private static SemanticType ResolveBuiltinType(string value) + private static SemanticType ResolveBuiltinType(string value, Discovery.AssemblyVisibility visibility) { // Split the @-origin format (#1538): "name@ClrFullName" or just "name". Resolution // goes through the shared, null-caching origin resolver — every channel that stores @@ -1831,7 +1837,7 @@ private static SemanticType ResolveBuiltinType(string value) if (atIndex >= 0) { name = value[..atIndex]; - resolvedClrType = Discovery.ClrTypeHelper.ResolveClrTypeByStoredName(value[(atIndex + 1)..]); + resolvedClrType = visibility.ResolveType(value[(atIndex + 1)..]); } else { diff --git a/src/Sharpy.Compiler/ProjectConfig.cs b/src/Sharpy.Compiler/ProjectConfig.cs index c393d8546..d16c4d92b 100644 --- a/src/Sharpy.Compiler/ProjectConfig.cs +++ b/src/Sharpy.Compiler/ProjectConfig.cs @@ -136,8 +136,9 @@ public class ProjectConfig /// path; its Include is only the assembly name and is not stored (a name that differs /// from the file's assembly is not diagnosed). Without a HintPath, Include is the path /// (rooted as written, or joined to ). A path that resolves to no - /// file is SPY0305, reported by every compiler door that builds a registry from this config - /// (). + /// file is SPY0305 (one that is not a loadable assembly, SPY0306), reported by every compiler + /// door that builds a registry from this config + /// (). /// public List References { get; init; } = new(); diff --git a/src/Sharpy.Compiler/Semantic/ConstEligibility.cs b/src/Sharpy.Compiler/Semantic/ConstEligibility.cs index fe0076b8e..08f429cb7 100644 --- a/src/Sharpy.Compiler/Semantic/ConstEligibility.cs +++ b/src/Sharpy.Compiler/Semantic/ConstEligibility.cs @@ -188,15 +188,15 @@ private void Collect(IEnumerable body, ConstScope scope, TypeSymbol? break; case ClassDef classDef: - CollectTypeBody(classDef.Name, classDef.Body, scope, owningType); + CollectTypeBody(classDef, classDef.Name, classDef.Body, scope, owningType); break; case StructDef structDef: - CollectTypeBody(structDef.Name, structDef.Body, scope, owningType); + CollectTypeBody(structDef, structDef.Name, structDef.Body, scope, owningType); break; case InterfaceDef interfaceDef: - CollectTypeBody(interfaceDef.Name, interfaceDef.Body, scope, owningType); + CollectTypeBody(interfaceDef, interfaceDef.Name, interfaceDef.Body, scope, owningType); break; case FunctionDef funcDef: @@ -214,23 +214,36 @@ private void Collect(IEnumerable body, ConstScope scope, TypeSymbol? } } - private void CollectTypeBody(string typeName, IEnumerable body, ConstScope scope, TypeSymbol? enclosing) + private void CollectTypeBody(Statement definition, string typeName, IEnumerable body, ConstScope scope, TypeSymbol? enclosing) { - var typeSymbol = ResolveTypeSymbol(typeName, enclosing); + // A type definition with no symbol of its own — one in a function body or a block, which the checker refuses + // (SPY0202: C# has no local types) — owns no fields to fold onto. Its body is not collected: walked with the + // symbol a NAME lookup found, its `const X` bound to that symbol's field X and folded onto it — an enclosing + // user type's, or a shared registry type's (#2250). + if (ResolveTypeSymbol(definition, typeName, enclosing) is not { } typeSymbol) + return; + // A type body allows forward references, as C# does. Collect(body, new ConstScope(scope, allowsForwardReference: true), typeSymbol); } /// - /// The declaring type of a nested type is its enclosing ; - /// a top-level type is looked up in the symbol table. A type declared inside a function has no - /// resolvable symbol here — its field consts get no fact and the emitter keeps the non-const - /// shape. + /// The symbol declared FOR : a nested type is in its enclosing + /// , a top-level type in the symbol table — in either case only the one + /// declared at this definition (), never a builtin's or another definition's of + /// the same name (#2250). /// - private TypeSymbol? ResolveTypeSymbol(string name, TypeSymbol? enclosing) - => enclosing != null - ? enclosing.NestedTypes.FirstOrDefault(t => t.Name == name) + private TypeSymbol? ResolveTypeSymbol(Statement definition, string name, TypeSymbol? enclosing) + { + var candidate = enclosing != null + ? enclosing.NestedTypes.FirstOrDefault(t => t.Name == name && t.IsDeclaredAt(definition)) : _symbolTable.Lookup(name) as TypeSymbol; + return candidate is not null + && !_symbolTable.BuiltinRegistry.IsBuiltinSymbol(candidate) + && candidate.IsDeclaredAt(definition) + ? candidate + : null; + } private void Declare(VariableDeclaration decl, ConstScope scope, TypeSymbol? owningType) { diff --git a/src/Sharpy.Compiler/Semantic/ProtocolMembership.cs b/src/Sharpy.Compiler/Semantic/ProtocolMembership.cs index 10377f769..8dda747d3 100644 --- a/src/Sharpy.Compiler/Semantic/ProtocolMembership.cs +++ b/src/Sharpy.Compiler/Semantic/ProtocolMembership.cs @@ -37,11 +37,18 @@ internal sealed class ProtocolMembership { private readonly IGlobalSymbolTable _symbolTable; private readonly BuiltinRegistry _builtins; - - public ProtocolMembership(IGlobalSymbolTable symbolTable, BuiltinRegistry builtins) + private readonly Discovery.AssemblyVisibility _visibility; + + /// + /// The compilation's CLR visibility: a generic recorded only by name resolves against it, never + /// against the process's loaded set (#2250). + /// + public ProtocolMembership(IGlobalSymbolTable symbolTable, BuiltinRegistry builtins, + Discovery.AssemblyVisibility visibility) { _symbolTable = symbolTable; _builtins = builtins; + _visibility = visibility; } /// @@ -208,14 +215,14 @@ public bool Has(SemanticType type, string dunderName) // definition from ClrOriginTypeName and check its closed form (#1517 honest borders). // When both GenericDefinition and ClrOriginTypeName are missing (the return type lost // its CLR identity through the overload-index → builtin-substitution chain), fall back - // to the display-name scan. Both lookups go through ClrTypeHelper's null-cached - // resolvers — a negative protocol probe must not rescan loaded assemblies each time. + // to the display-name scan. Both lookups go through the compilation's visibility, which + // null-caches per name — a negative protocol probe must not rescan each time (#2250). if (generic.ClrOriginTypeName is { Length: > 0 } || symTableType == null) { var arity = generic.TypeArguments.Count; var resolvedDef = generic.ClrOriginTypeName is { Length: > 0 } originName - ? Discovery.ClrTypeHelper.ResolveClrTypeByStoredName(originName) - : Discovery.ClrTypeHelper.ResolveGenericDefinitionByDisplayName(generic.Name, arity); + ? _visibility.ResolveType(originName) + : _visibility.ResolveGenericDefinitionByDisplayName(generic.Name, arity); if (resolvedDef is { IsGenericTypeDefinition: true } && resolvedDef.GetGenericArguments().Length == arity) diff --git a/src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs b/src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs index 70ae584aa..0b05d0343 100644 --- a/src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs +++ b/src/Sharpy.Compiler/Semantic/Registry/BuiltinRegistry.cs @@ -22,19 +22,14 @@ internal class BuiltinRegistry private readonly CachedModuleDiscovery _discovery; private readonly ClrTypeBridge _clrTypeMapper = new(); - /// - /// Deferred interface population work recorded by and processed - /// after all builtin types are registered, so that interface definitions can be resolved - /// against already-registered symbols (e.g., IEnumerable, IEnumerator). - /// - private readonly List<(TypeSymbol Symbol, TypeParameterType[] TypeParams)> _pendingInterfacePopulation = new(); - /// /// Cache of minimal interface TypeSymbols keyed by the open-generic (or non-generic) CLR /// interface definition, so all builtin types implementing the same interface share one - /// definition symbol. + /// definition symbol. Filled by + /// with a value that is a function of the key alone, so a fill after construction — none + /// happens today — could neither race nor depend on which analysis asked first (#2250). /// - private readonly Dictionary _interfaceSymbols = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _interfaceSymbols = new(); /// /// Names excluded from primitive registration because they are registered separately @@ -78,14 +73,108 @@ internal class BuiltinRegistry "Some", "Ok", "Err" }; - public BuiltinRegistry(ICompilerLogger? logger = null) + /// + /// The CLR types this registry's bare-name fallback () may see: + /// the paired ModuleRegistry's visibility — a builtin registry is built for ONE reference + /// set, beside that set's module registry — or the framework set alone (#2250). Never whatever the + /// process has loaded. + /// + private readonly AssemblyVisibility _clrFallbackVisibility; + + /// Optional logger. + /// + /// The paired module registry's visibility; null gives the framework set alone + /// (). + /// + public BuiltinRegistry(ICompilerLogger? logger = null, AssemblyVisibility? clrFallbackVisibility = null) { + _clrFallbackVisibility = clrFallbackVisibility ?? AssemblyVisibility.FrameworkOnly; _discovery = new CachedModuleDiscovery(null, logger); LoadBuiltins(); + FreezeSymbols(); + } + + /// + /// Freezes every symbol the registry built: a CompilerApi hands this ONE instance to + /// every analysis it performs (#1633 retired the per-compilation clone), so a symbol reachable + /// from it must never be a write target — analysing a user def len inside a try: + /// body wrote the builtin len's parameters and every later document read the user's + /// signature (#2250). After this, a write throws () instead of + /// retyping the builtin for the rest of the process. + /// + private void FreezeSymbols() + { + var roots = _types.Values.Cast() + .Concat(_functions.Values.SelectMany(overloads => overloads)) + .Concat(_interfaceSymbols.Values); + var seen = new HashSet(); + var pending = new Stack(roots); + while (pending.Count > 0) + { + var symbol = pending.Pop(); + if (!seen.Add(symbol)) + continue; + + symbol.Freeze(); + if (symbol is not TypeSymbol type) + continue; + + foreach (var member in OwnedSymbols(type)) + { + if (member is not null) + pending.Push(member); + } + } + } + + /// The symbols a builtin holds — its members and the types it names. + private static IEnumerable OwnedSymbols(TypeSymbol type) + { + foreach (var method in type.Methods) + yield return method; + foreach (var constructor in type.Constructors) + yield return constructor; + foreach (var field in type.Fields) + yield return field; + foreach (var overloads in type.MethodOverloads.Values + .Concat(type.OperatorMethods.Values) + .Concat(type.ProtocolMethods.Values)) + { + foreach (var overload in overloads) + yield return overload; + } + + foreach (var nested in type.NestedTypes.Concat(type.UnionCases)) + yield return nested; + foreach (var alias in type.NestedTypeAliases) + yield return alias; + foreach (var iface in type.Interfaces) + yield return iface.Definition; + if (type.DataclassFields is { } dataclassFields) + { + foreach (var field in dataclassFields) + yield return field; + } + + if (type.ClrArityGroup is { } arityGroup) + { + foreach (var sibling in arityGroup.Values) + yield return sibling; + } + + yield return type.BaseType; + yield return type.DeclaringType; } private void LoadBuiltins() { + // Interface population is deferred until every builtin type is registered, so interface + // definitions resolve to registered symbols (#827). The work list lives in this method and + // is drained before it returns: no registration work survives construction (#2250). + var pendingInterfacePopulation = new List<(TypeSymbol Symbol, TypeParameterType[] TypeParams)>(); + void Register(string sharpyName, Type clrType, TypeKind kind, bool isGeneric = false, int typeParamCount = 0) + => RegisterType(sharpyName, clrType, kind, pendingInterfacePopulation, isGeneric, typeParamCount); + // Load Sharpy.Core assembly first so discovery data is available for RegisterType var sharpyCoreAssembly = typeof(SharpyRT::Sharpy.Builtins).Assembly; _discovery.LoadAssembly(sharpyCoreAssembly); @@ -117,33 +206,33 @@ private void LoadBuiltins() } var kind = info.ClrType.IsValueType ? TypeKind.Struct : TypeKind.Class; - RegisterType(info.SharpyName, info.ClrType, kind); + Register(info.SharpyName, info.ClrType, kind); registeredClrTypes[info.ClrType] = info.SharpyName; } // Collections (generic) - use Sharpy.Core wrapper types - RegisterType("list", typeof(SharpyRT::Sharpy.List<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); + Register("list", typeof(SharpyRT::Sharpy.List<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); // dict was left on System.Collections.Generic.Dictionary<,> when 616a07c9b moved its // siblings to the wrappers. The CLR type recorded here is what operator resolution // reflects over, and Dictionary declares no operators, so every `dict op dict` had to be // served by the single-candidate shortcut in TypeInferenceService.FindBestOverload; a // second `|` overload took that shortcut away and `dict | dict` stopped resolving (#1361). - RegisterType("dict", typeof(SharpyRT::Sharpy.Dict<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); - RegisterType("set", typeof(SharpyRT::Sharpy.Set<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); - RegisterType(BuiltinNames.FrozenDict, typeof(SharpyRT::Sharpy.FrozenDict<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); - RegisterType(BuiltinNames.FrozenSet, typeof(SharpyRT::Sharpy.FrozenSet<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); + Register("dict", typeof(SharpyRT::Sharpy.Dict<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); + Register("set", typeof(SharpyRT::Sharpy.Set<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); + Register(BuiltinNames.FrozenDict, typeof(SharpyRT::Sharpy.FrozenDict<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); + Register(BuiltinNames.FrozenSet, typeof(SharpyRT::Sharpy.FrozenSet<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); // Bytes (non-generic) - immutable byte sequence - RegisterType("bytes", typeof(SharpyRT::Sharpy.Bytes), TypeKind.Struct); + Register("bytes", typeof(SharpyRT::Sharpy.Bytes), TypeKind.Struct); // Slice — the Python `slice` type used in `__getitem__(self, s: slice)` (#1610). - RegisterType("slice", typeof(SharpyRT::Sharpy.Slice), TypeKind.Struct); + Register("slice", typeof(SharpyRT::Sharpy.Slice), TypeKind.Struct); // complex: the Python spelling. `Complex` already resolved through the Sharpy-namespace // discovery rule — this is not a rename, it registers the lowercase name a Python reader // actually writes, and both spellings resolve afterwards (#1362). Every other builtin is // lowercase; Complex was the outlier. - RegisterType(BuiltinNames.Complex, typeof(SharpyRT::Sharpy.Complex), TypeKind.Struct); + Register(BuiltinNames.Complex, typeof(SharpyRT::Sharpy.Complex), TypeKind.Struct); // Tuple: registered for OperatorValidator/ProtocolValidator metadata lookup. // typeParamCount=1 is nominal — real tuple arity is tracked by TupleType.ElementTypes, @@ -154,20 +243,20 @@ private void LoadBuiltins() // ClrTypeBridge.SpecialCases (-> "System.ValueTuple") and a written tuple annotation is // emitted from TupleType through MapSemanticType's per-arity ValueTuple arm — so the // sentinel never reaches the global::-qualification arm in TypeSyntaxMapper (#1765). - RegisterType(BuiltinNames.Tuple, typeof(System.ValueTuple), TypeKind.Struct, isGeneric: true, typeParamCount: 1); + Register(BuiltinNames.Tuple, typeof(System.ValueTuple), TypeKind.Struct, isGeneric: true, typeParamCount: 1); // Dict view types (returned by dict.items(), .keys(), .values()). // These named their real CLR types only after #1346: they were registered against // typeof(object) placeholders, which PopulateClrInterfaces skips, so none of them carried a // CLR interface list. Naming the real type is what gives them one. - RegisterType(BuiltinNames.DictItemsView, typeof(SharpyRT::Sharpy.DictItemsView<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); - RegisterType(BuiltinNames.DictKeyView, typeof(SharpyRT::Sharpy.DictKeyView<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); - RegisterType(BuiltinNames.DictValuesView, typeof(SharpyRT::Sharpy.DictValuesView<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); + Register(BuiltinNames.DictItemsView, typeof(SharpyRT::Sharpy.DictItemsView<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); + Register(BuiltinNames.DictKeyView, typeof(SharpyRT::Sharpy.DictKeyView<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); + Register(BuiltinNames.DictValuesView, typeof(SharpyRT::Sharpy.DictValuesView<,>), TypeKind.Class, isGeneric: true, typeParamCount: 2); // Iterator/iterable types (used by generators and reversed()). Sharpy.Iterator is // ABSTRACT, which is the fact that makes a reference to it non-constructible (#1346) — // recorded here rather than asserted in prose, so NonConstructibleTypeNameOf reads it. - RegisterType(BuiltinNames.Iterator, typeof(SharpyRT::Sharpy.Iterator<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); + Register(BuiltinNames.Iterator, typeof(SharpyRT::Sharpy.Iterator<>), TypeKind.Class, isGeneric: true, typeParamCount: 1); // The registered ClrType is the GENERIC definition Sharpy actually maps these names to. // They carried the non-generic System.Collections.IEnumerable/IEnumerator until #1765: // a symbol declaring one type parameter whose ClrType had none made every consumer that @@ -175,19 +264,19 @@ private void LoadBuiltins() // 311252e33 papered over by exempting ALL generic types from global:: qualification. // Naming the definition here removes the need for both the exemption and the separate // varianceSource argument (ApplyClrVariance now reads the variance off the ClrType). - RegisterType(BuiltinNames.IEnumerable, typeof(IEnumerable<>), TypeKind.Interface, isGeneric: true, typeParamCount: 1); - RegisterType(BuiltinNames.IEnumerator, typeof(IEnumerator<>), TypeKind.Interface, isGeneric: true, typeParamCount: 1); + Register(BuiltinNames.IEnumerable, typeof(IEnumerable<>), TypeKind.Interface, isGeneric: true, typeParamCount: 1); + Register(BuiltinNames.IEnumerator, typeof(IEnumerator<>), TypeKind.Interface, isGeneric: true, typeParamCount: 1); // Result and Optional (for semantic-time method/property resolution) - RegisterType("Result", typeof(SharpyRT::Sharpy.Result<,>), TypeKind.Struct, isGeneric: true, typeParamCount: 2); - RegisterType("Optional", typeof(SharpyRT::Sharpy.Optional<>), TypeKind.Struct, isGeneric: true, typeParamCount: 1); + Register("Result", typeof(SharpyRT::Sharpy.Result<,>), TypeKind.Struct, isGeneric: true, typeParamCount: 2); + Register("Optional", typeof(SharpyRT::Sharpy.Optional<>), TypeKind.Struct, isGeneric: true, typeParamCount: 1); // Template (PEP 750) — t"..." literal type - RegisterType(BuiltinNames.Template, typeof(SharpyRT::Sharpy.Template), TypeKind.Class); + Register(BuiltinNames.Template, typeof(SharpyRT::Sharpy.Template), TypeKind.Class); // Special - RegisterType("object", typeof(object), TypeKind.Class); - RegisterType("None", typeof(void), TypeKind.Struct); // void for return type + Register("object", typeof(object), TypeKind.Class); + Register("None", typeof(void), TypeKind.Struct); // void for return type // Load builtin functions using reflection-based discovery LoadBuiltinFunctions(); @@ -202,7 +291,7 @@ private void LoadBuiltins() // Register System.Exception as a base type for catch clauses if (!_types.ContainsKey("Exception")) { - RegisterType("Exception", typeof(System.Exception), TypeKind.Class); + Register("Exception", typeof(System.Exception), TypeKind.Class); } // Wire up BaseType for discovered exception types whose CLR base is outside the @@ -213,7 +302,7 @@ private void LoadBuiltins() // Populate CLR interface information on registered TypeSymbols. Deferred until all // types are registered so interface definitions resolve to registered symbols (#827). - PopulateClrInterfaces(); + PopulateClrInterfaces(pendingInterfacePopulation); } private void LoadBuiltinTypes() @@ -331,7 +420,13 @@ private void LoadBuiltinFunctions() } } - private void RegisterType(string sharpyName, Type clrType, TypeKind kind, bool isGeneric = false, int typeParamCount = 0) + private void RegisterType( + string sharpyName, + Type clrType, + TypeKind kind, + List<(TypeSymbol Symbol, TypeParameterType[] TypeParams)> pendingInterfacePopulation, + bool isGeneric = false, + int typeParamCount = 0) { // Build shared TypeParameterType instances for generic types so all methods // reference the same objects (required for consistent name-based substitution). @@ -376,10 +471,10 @@ private void RegisterType(string sharpyName, Type clrType, TypeKind kind, bool i // Element widening is written, not inferred: a slot-directed literal (`xs: list[object] = [1]`) // or an explicit reconstruction (`list[Animal](ds)`). - var methods = discovered?.Methods ?? new List(); - var operatorMethods = discovered?.OperatorMethods ?? new Dictionary>(); - var protocolMethods = discovered?.ProtocolMethods ?? new Dictionary>(); - var properties = discovered?.Properties ?? new List(); + var methods = discovered?.Methods ?? new SymbolMemberList(); + var operatorMethods = discovered?.OperatorMethods ?? new SymbolOverloadTable(); + var protocolMethods = discovered?.ProtocolMethods ?? new SymbolOverloadTable(); + var properties = discovered?.Properties ?? new SymbolMemberList(); // For types not discoverable from Sharpy.Core, provide inline definitions. ApplyNonDiscoverableDefinitions(sharpyName, ref methods, ref operatorMethods, ref protocolMethods); @@ -433,7 +528,7 @@ private void RegisterType(string sharpyName, Type clrType, TypeKind kind, bool i // Defer interface population until all builtin types are registered, so that // interface definitions (e.g., IEnumerable) resolve to the registered symbols. - _pendingInterfacePopulation.Add((typeSymbol, sharedTypeParams)); + pendingInterfacePopulation.Add((typeSymbol, sharedTypeParams)); } /// @@ -441,7 +536,7 @@ private void RegisterType(string sharpyName, Type clrType, TypeKind kind, bool i /// from the generic type /// definition's parameters (#827). Non-generic sources leave the definitions unchanged. /// - private static List ApplyClrVariance(List typeParams, Type? clrSource) + private static SymbolMemberList ApplyClrVariance(SymbolMemberList typeParams, Type? clrSource) { if (typeParams.Count == 0 || clrSource is not { IsGenericTypeDefinition: true }) return typeParams; @@ -464,13 +559,13 @@ private static List ApplyClrVariance(List ty /// TypeHierarchyService and generic inference can walk interface hierarchies of /// builtin types (#827). /// - private void PopulateClrInterfaces() + private void PopulateClrInterfaces(List<(TypeSymbol Symbol, TypeParameterType[] TypeParams)> pendingInterfacePopulation) { - foreach (var (typeSymbol, typeParams) in _pendingInterfacePopulation) + foreach (var (typeSymbol, typeParams) in pendingInterfacePopulation) { PopulateClrInterfaces(typeSymbol, typeParams); } - _pendingInterfacePopulation.Clear(); + pendingInterfacePopulation.Clear(); } private void PopulateClrInterfaces(TypeSymbol typeSymbol, TypeParameterType[] sharedTypeParams) @@ -597,9 +692,13 @@ private TypeSymbol GetOrCreateInterfaceSymbol(Type interfaceDef) if (_types.TryGetValue(name, out var registered) && registered.TypeKind == TypeKind.Interface) return registered; - if (_interfaceSymbols.TryGetValue(interfaceDef, out var cached)) - return cached; + return _interfaceSymbols.GetOrAdd(interfaceDef, CreateInterfaceSymbol); + } + /// The minimal interface symbol for — a function of the definition alone. + private static TypeSymbol CreateInterfaceSymbol(Type interfaceDef) + { + var name = ClrNameHelper.StripArity(interfaceDef.Name); var clrArgs = interfaceDef.IsGenericTypeDefinition ? interfaceDef.GetGenericArguments() : Type.EmptyTypes; @@ -607,7 +706,7 @@ private TypeSymbol GetOrCreateInterfaceSymbol(Type interfaceDef) .Select((arg, i) => new TypeParameterDef { Name = $"T{i}", Variance = ClrTypeBridge.GetClrVariance(arg) }) .ToList(); - var symbol = new TypeSymbol + return new TypeSymbol { Name = name, Kind = SymbolKind.Type, @@ -616,8 +715,6 @@ private TypeSymbol GetOrCreateInterfaceSymbol(Type interfaceDef) TypeParameters = typeParams, AccessLevel = AccessLevel.Public }; - _interfaceSymbols[interfaceDef] = symbol; - return symbol; } /// @@ -670,9 +767,9 @@ private static void PopulateMethodOverloads(TypeSymbol typeSymbol) /// private void ApplyNonDiscoverableDefinitions( string typeName, - ref List methods, - ref Dictionary> operatorMethods, - ref Dictionary> protocolMethods) + ref SymbolMemberList methods, + ref SymbolOverloadTable operatorMethods, + ref SymbolOverloadTable protocolMethods) { switch (typeName) { @@ -733,7 +830,7 @@ private void ApplyNonDiscoverableDefinitions( /// and adds them as instance method FunctionSymbols. The this string first parameter /// is stripped since the TypeChecker sees these as instance methods on str. /// - private void DiscoverStringExtensionMethods(ref List methods) + private void DiscoverStringExtensionMethods(ref SymbolMemberList methods) { var sharpyCoreAssembly = typeof(SharpyRT::Sharpy.Builtins).Assembly; var extensionType = sharpyCoreAssembly.GetType("Sharpy.StringExtensions"); @@ -1116,12 +1213,13 @@ public bool IsBuiltinSymbol(Symbol symbol) { var registryOwnsName = IsReservedBuiltinTypeName(name) || CamelCaseAliases.ContainsKey(name); - // The extra state is a pure function of the KEY's name, so it can never make two callers - // disagree about a cached entry. - return _clrTypeCache.GetOrAdd((name, arity), static ((string Name, int Arity) key, bool ownedByRegistry) => + // The extra state is a pure function of the KEY's name and of this registry's construction-time + // visibility, so it can never make two callers disagree about a cached entry. + return _clrTypeCache.GetOrAdd((name, arity), static ((string Name, int Arity) key, + (bool OwnedByRegistry, AssemblyVisibility Visibility) state) => { var (n, a) = key; - var clrType = TryFindClrType(n, a, allowSharpyNamespace: !ownedByRegistry); + var clrType = TryFindClrType(state.Visibility, n, a, allowSharpyNamespace: !state.OwnedByRegistry); if (clrType == null) return null; @@ -1159,7 +1257,7 @@ public bool IsBuiltinSymbol(Symbol symbol) } return sym; - }, registryOwnsName); + }, (registryOwnsName, _clrFallbackVisibility)); } /// @@ -1196,28 +1294,23 @@ public bool IsBuiltinSymbol(Symbol symbol) /// keeps Dict/List off the reflected Sharpy.Dict/Sharpy.List /// (see , #1625). /// - private static Type? TryFindClrType(string name, int arity, bool allowSharpyNamespace) + private static Type? TryFindClrType(AssemblyVisibility visibility, string name, int arity, bool allowSharpyNamespace) { // #1613: for arity > 0, probe `Name`N (e.g. Action`1, Func`3) var clrName = arity > 0 ? $"{name}`{arity}" : name; - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + // The registry's construction-time visibility (#2250): the framework set plus the references + // of the ONE reference set this registry was built for — the stdlib's Sharpy-namespace classes + // that declare no module (StatisticsError, CapturedOutput, ...) are reachable only through this + // fallback — and never whatever else the process has loaded. foreach (var ns in ClrFallbackNamespaces) { if (!allowSharpyNamespace && ClrTypeBridge.SpecialCases.IsSharpyNamespace(ns)) continue; - var fullName = $"{ns}.{clrName}"; - var type = Type.GetType(fullName); + var type = visibility.ResolveType($"{ns}.{clrName}"); if (Accepts(type)) return type; - - foreach (var assembly in assemblies) - { - type = assembly.GetType(fullName); - if (Accepts(type)) - return type; - } } return null; diff --git a/src/Sharpy.Compiler/Semantic/Registry/ModuleRegistry.cs b/src/Sharpy.Compiler/Semantic/Registry/ModuleRegistry.cs index 9698ccb05..f594ee4a1 100644 --- a/src/Sharpy.Compiler/Semantic/Registry/ModuleRegistry.cs +++ b/src/Sharpy.Compiler/Semantic/Registry/ModuleRegistry.cs @@ -24,35 +24,45 @@ internal class ModuleRegistry private readonly DiagnosticBag _diagnostics = new(); private readonly ConcurrentDictionary _assemblyNameToPath = new(); private readonly ConcurrentDictionary _usedAssemblyPaths = new(); - private readonly ConcurrentDictionary _dynamicNamespaceCache = new(); + + /// + /// The load contexts this registry's non-framework references live in (#2250, P54 Design + /// Decision 2), keyed by its reference set (). Held + /// strongly for the registry's life — the process-wide share holds them only weakly, so holding + /// them here is what keeps a context from unloading under a compilation still using it. + /// + private readonly List _loadContexts = new(); public ModuleRegistry(ICompilerLogger? logger = null, OverloadIndexCache? cache = null) { _logger = logger ?? NullLogger.Instance; - _discovery = new CachedModuleDiscovery(cache, _logger); + Visibility = new AssemblyVisibility(); + _discovery = new CachedModuleDiscovery(cache, _logger, Visibility); } + /// + /// The CLR types and namespaces this registry's compilation may see: the framework set plus the + /// assemblies this registry loaded — never whatever else the process has loaded (#2250). Every + /// CLR-name question the compilation asks goes through it. + /// + public AssemblyVisibility Visibility { get; } + public DiagnosticBag Diagnostics => _diagnostics; /// - /// The registry's SPY0305 () - /// diagnostics: one per reference that resolved to no file. The one read every door that builds - /// a registry from a makes before it compiles or reports (#2173, - /// #2223) — Compiler.CompileProject refuses with these before anything is written, - /// CompilerApi.CompileAsSyntheticProject likewise, and CompilerApi.AnalyzeProject - /// merges them into its result, where the file-less entry lands on the project's unattributed - /// target. + /// Every registry error: SPY0305 (, a + /// reference that resolved to no file) and SPY0306 (, + /// a reference that cannot be loaded as given — not an assembly, unreadable). The one read every + /// door that builds a registry from a makes before it compiles or + /// reports (#2173, #2223, R-HI) — Compiler.CompileProject refuses with these before + /// anything is written, CompilerApi.CompileAsSyntheticProject likewise, and + /// CompilerApi.AnalyzeProject merges them into its result, where the file-less entry lands + /// on the project's unattributed target. SPY0306 has ONE meaning since each registry loads its + /// references into its own load context (#2250): a same-named assembly another compilation + /// loaded from another path can no longer collide with this one's. /// - /// - /// Only the missing-path code: SPY0306 also fires when a long-lived process (a test host, the - /// compile server) already loaded a same-named assembly from another path, and that compile - /// goes on to succeed against the loaded copy — a different defect, not this one. Refusing on - /// every registry error reddened run-route cells when this was first measured (fa7e8aa63). - /// - public IReadOnlyList MissingReferenceDiagnostics() - => _diagnostics.GetAll() - .Where(d => d.Code == DiagnosticCodes.Semantic.AssemblyNotFound) - .ToList(); + public IReadOnlyList ReferenceDiagnostics() + => _diagnostics.GetAll().ToList(); /// /// The assemblies loaded as module references so far. Consulted by the TypeChecker's BCL @@ -99,9 +109,25 @@ public bool LoadReference(string assemblyPath) return false; } - // Load the assembly - var assembly = Assembly.LoadFrom(resolvedPath); - var assemblyName = assembly.GetName().Name ?? Path.GetFileNameWithoutExtension(resolvedPath); + // Load the assembly into a load context keyed by this registry's reference set (#2250) — + // never the process's default context, where a same-named copy another compilation loaded + // would collide (the old SPY0306 arm) and stay visible to every later compile. A framework + // assembly (the shared framework, or Sharpy.Core — a default reference) keeps the one + // identity the compiler was built against: the framework copy is used, not a second one. A + // reference named like one of the HOST's own packages (System.CommandLine under sharpyc) + // is not framework: the user's file is loaded, never the host's copy (#2250). A simple name this registry + // already loaded keeps its first copy (first wins, as before), without loading another. + var identity = AssemblyName.GetAssemblyName(resolvedPath); + var simpleName = identity.Name ?? Path.GetFileNameWithoutExtension(resolvedPath); + if (_loadedAssemblies.ContainsKey(simpleName)) + { + _logger.LogDebug($"Assembly '{simpleName}' already loaded"); + return true; + } + + var assembly = AssemblyVisibility.FindFrameworkAssembly(simpleName) + ?? LoadIntoReferenceContext(resolvedPath); + var assemblyName = assembly.GetName().Name ?? simpleName; // Use TryAdd for atomic check-and-add operation if (!_loadedAssemblies.TryAdd(assemblyName, assembly)) @@ -135,6 +161,31 @@ public bool LoadReference(string assemblyPath) } } + /// + /// The registry's copy of the non-framework reference at : an assembly + /// one of its contexts already loaded from that file (as a dependency of an earlier reference), else + /// the reference-set-keyed context's (#2250). + /// + private Assembly LoadIntoReferenceContext(string fullPath) + { + lock (_loadContexts) + { + foreach (var held in _loadContexts) + { + foreach (var loaded in held.Assemblies) + { + if (string.Equals(loaded.Location, fullPath, StringComparison.Ordinal)) + return loaded; + } + } + + var context = ReferenceLoadContext.GetOrLoad( + fullPath, Visibility.OwnAssemblies, GetModulePaths().ToList()); + _loadContexts.Add(context); + return context.Reference; + } + } + /// /// Get all functions exported by a specific module. /// Module name should match the assembly/namespace name. @@ -314,30 +365,13 @@ public void ClearCache() return null; } - // Construct the full .NET type name + // Construct the full .NET type name and resolve it against THIS compilation's visibility. var fullTypeName = $"{netNamespace}.{typeName}"; - - // Try to find the type in loaded assemblies and the runtime - var clrType = Type.GetType(fullTypeName); - if (clrType != null) - { - _logger.LogDebug($"Resolved .NET type: {fullTypeName}"); - return clrType; - } - - // Try searching in all loaded assemblies - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - clrType = assembly.GetType(fullTypeName); - if (clrType != null) - { - _logger.LogDebug($"Resolved .NET type from assembly {assembly.GetName().Name}: {fullTypeName}"); - return clrType; - } - } - - _logger.LogDebug($".NET type not found: {fullTypeName}"); - return null; + var clrType = Visibility.ResolveType(fullTypeName); + _logger.LogDebug(clrType != null + ? $"Resolved .NET type: {fullTypeName}" + : $".NET type not found: {fullTypeName}"); + return clrType; } /// @@ -369,9 +403,6 @@ public List GetNamespaceTypes(string moduleName) if (netNamespace == null) return types; - // Ensure runtime assemblies for this namespace are loaded (e.g., System.IO.FileSystem for System.IO) - EnsureRuntimeAssembliesLoaded(netNamespace); - // #1613: a CLR namespace spells some names as a GROUP of types differing only in generic // arity (EventHandler / EventHandler`1 / EventHandler`2, Action`0..16, Func`1..17). // StripArity collapses the group onto one export key, and which member survived was @@ -379,33 +410,21 @@ public List GetNamespaceTypes(string moduleName) // representative carrying the whole group for annotation-site selection. var byName = new Dictionary>(StringComparer.Ordinal); - // Search all loaded assemblies for types in this namespace - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + // The namespace's public types in this compilation's visibility — the framework set is a + // process constant (no namespace-driven loading left to race), plus this registry's own + // references (#2250). + foreach (var clrType in Visibility.TypesInNamespace(netNamespace)) { - try - { - var namespaceTypes = assembly.GetTypes() - .Where(t => t.IsPublic && t.Namespace == netNamespace); + var typeSymbol = CreateTypeSymbolFromClrType(clrType); + if (typeSymbol == null) + continue; - foreach (var clrType in namespaceTypes) - { - var typeSymbol = CreateTypeSymbolFromClrType(clrType); - if (typeSymbol == null) - continue; - - if (!byName.TryGetValue(typeSymbol.Name, out var group)) - { - group = new SortedDictionary(); - byName[typeSymbol.Name] = group; - } - group[typeSymbol.TypeParameters.Count] = typeSymbol; - } - } - catch (ReflectionTypeLoadException ex) + if (!byName.TryGetValue(typeSymbol.Name, out var group)) { - // Skip assemblies that can't be fully loaded - _logger.LogDebug($"Skipping assembly '{assembly.GetName().Name}': {ex.Message}"); + group = new SortedDictionary(); + byName[typeSymbol.Name] = group; } + group[typeSymbol.TypeParameters.Count] = typeSymbol; } foreach (var group in byName.Values) @@ -597,33 +616,17 @@ private static ImmutableArray MapClrBaseTypeArguments( return string.Join(".", result); } - // For non-system/sharpy prefixes (e.g., NuGet packages), try PascalCase - // conversion and verify the namespace exists in a loaded assembly. - return _dynamicNamespaceCache.GetOrAdd(lower, _ => + // For non-system/sharpy prefixes (e.g., NuGet packages), try PascalCase conversion and + // verify a VISIBLE assembly declares the namespace — this compilation's references or the + // framework set, never another compilation's load (#2250). + var parts = lower.Split('.'); + var candidate = new string[parts.Length]; + for (int i = 0; i < parts.Length; i++) { - var segments = lower.Split('.'); - var candidate = new string[segments.Length]; - for (int i = 0; i < segments.Length; i++) - { - candidate[i] = NameMangler.ToModuleIdentifier(segments[i], AcronymPolicy.IoOnly); - } - var candidateNs = string.Join(".", candidate); - - foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) - { - try - { - if (assembly.GetTypes().Any(t => t.IsPublic && t.Namespace == candidateNs)) - return candidateNs; - } - catch (ReflectionTypeLoadException) - { - // Skip assemblies that can't be fully loaded - } - } - - return null; - }); + candidate[i] = NameMangler.ToModuleIdentifier(parts[i], AcronymPolicy.IoOnly); + } + var candidateNs = string.Join(".", candidate); + return Visibility.NamespaceExists(candidateNs) ? candidateNs : null; } /// @@ -632,7 +635,7 @@ private static ImmutableArray MapClrBaseTypeArguments( /// 2. Relative to current directory /// 3. Module search paths /// Note: TOCTOU (Time-of-Check-Time-of-Use) race condition is acceptable here - /// as any file system changes between this check and Assembly.LoadFrom() are + /// as any file system changes between this check and the load in LoadReference() are /// handled by exception catching (IOException, UnauthorizedAccessException) in LoadReference(). /// private string? ResolveAssemblyPath(string assemblyPath) => ResolveAssemblyPath(assemblyPath, _modulePaths); @@ -692,71 +695,4 @@ private static ImmutableArray MapClrBaseTypeArguments( return null; } - - private static readonly ConcurrentDictionary _loadedRuntimeNamespaces = new(StringComparer.Ordinal); - - /// - /// Loads runtime assemblies whose names match or start with the given .NET namespace. - /// This ensures types like System.IO.DriveInfo (in System.IO.FileSystem.dll) are - /// discoverable even when the hosting assembly isn't loaded by default. - /// - private void EnsureRuntimeAssembliesLoaded(string netNamespace) - { - if (!_loadedRuntimeNamespaces.TryAdd(netNamespace, 0)) - return; - - var tpaString = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; - if (tpaString == null) - return; - - // Build match prefixes: the namespace itself plus parent namespaces. - // .NET distributes types across assemblies that don't always match the - // namespace — e.g., System.Net.Sockets.AddressFamily is in - // System.Net.Primitives.dll. Loading parent-namespace assemblies - // catches these cross-assembly cases. - var prefixes = new List(); - prefixes.Add(netNamespace + "."); - prefixes.Add(netNamespace); - var parts = netNamespace.Split('.'); - for (int i = parts.Length - 1; i >= 2; i--) - { - var parent = string.Join(".", parts, 0, i); - prefixes.Add(parent + "."); - } - - foreach (var path in tpaString.Split(Path.PathSeparator)) - { - var fileName = Path.GetFileNameWithoutExtension(path); - if (fileName == null) - continue; - - bool matches = false; - foreach (var prefix in prefixes) - { - if (fileName.StartsWith(prefix, StringComparison.Ordinal) || - string.Equals(fileName, prefix.TrimEnd('.'), StringComparison.Ordinal)) - { - matches = true; - break; - } - } - if (!matches) - continue; - - if (AppDomain.CurrentDomain.GetAssemblies().Any( - a => string.Equals(a.GetName().Name, fileName, StringComparison.Ordinal))) - continue; - - try - { - Assembly.LoadFrom(path); - _logger.LogDebug($"Loaded runtime assembly '{fileName}' for namespace '{netNamespace}'"); - } - catch (Exception ex) - { - _logger.LogDebug($"Failed to load runtime assembly '{fileName}': {ex.Message}"); - } - } - } - } diff --git a/src/Sharpy.Compiler/Semantic/Symbol.cs b/src/Sharpy.Compiler/Semantic/Symbol.cs index 6eab56f42..ced3800d4 100644 --- a/src/Sharpy.Compiler/Semantic/Symbol.cs +++ b/src/Sharpy.Compiler/Semantic/Symbol.cs @@ -20,7 +20,7 @@ public abstract record Symbol public string Name { get; init; } = string.Empty; public SymbolKind Kind { get; init; } public bool IsNameBacktickEscaped { get; init; } - public AccessLevel AccessLevel { get; internal set; } = AccessLevel.Public; + public AccessLevel AccessLevel { get; internal set { ThrowIfFrozen(); field = value; } } = AccessLevel.Public; public int? DeclarationLine { get; init; } public int? DeclarationColumn { get; init; } @@ -130,7 +130,7 @@ public abstract record Symbol /// The access level explicitly set via decorator (@public, @protected, @private, @internal). /// When non-null, this overrides the name-based access level convention. /// - public AccessLevel? ExplicitAccessLevel { get; internal set; } + public AccessLevel? ExplicitAccessLevel { get; internal set { ThrowIfFrozen(); field = value; } } /// /// If non-null, this symbol is deprecated and this is the deprecation message. @@ -148,7 +148,7 @@ public abstract record Symbol /// during name resolution. For .NET assemblies, populated from XML doc files /// during assembly discovery. /// - public string? Documentation { get; internal set; } + public string? Documentation { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Use reference equality for symbols. Record default (value equality) is unsafe @@ -159,6 +159,63 @@ public abstract record Symbol public override int GetHashCode() => RuntimeHelpers.GetHashCode(this); public override string ToString() => $"{Kind} '{Name}'"; + + /// + /// The symbol this one was frozen as. A symbol is frozen when this is the symbol itself, so a + /// with copy — which copies the field but is a different object — is born unfrozen: the + /// copy is a new declaration its maker owns, the original stays the master's. + /// + private Symbol? _frozenAs; + + /// + /// True for a symbol owned by a master , frozen when its + /// construction completes. Every analysis on a CompilerApi reads the same master (the + /// per-compilation clone was retired at #1633), so a write to one is a write every later + /// document reads — a user def len inside a try: body retyped the builtin + /// len for every document analysed after it (#2250). + /// + internal bool IsFrozen => ReferenceEquals(_frozenAs, this); + + /// + /// Freezes this symbol: every settable property, and every member list or overload table it owns + /// (, ), throws on a write from + /// now on (#2250). + /// + internal void Freeze() + { + _frozenAs = this; + FreezeMembers(); + } + + /// Freezes the member collections this symbol owns — every one of them (#2250). + private protected virtual void FreezeMembers() + { + } + + /// + /// True when this symbol was declared FOR : the declaring site stamps the + /// definition's span (its start position for a span-less, synthesized definition). A same-named symbol found by a + /// name lookup — a builtin, an import, an enclosing definition — carries another span or none. The one rule every + /// pass that WRITES the symbol of a definition it is walking applies before writing (#2250). + /// + internal bool IsDeclaredAt(Parser.Ast.Node definition) + => definition.Span is { } span + ? DeclarationSpan == span + : DeclarationLine == definition.LineStart && DeclarationColumn == definition.ColumnStart; + + /// + /// Refuses a write to a frozen symbol — always on, not DEBUG-only, like + /// 's phase freezes: a write that reached a master symbol is a + /// silent retype of every later document, and an internal error is the better failure. + /// + private protected void ThrowIfFrozen([CallerMemberName] string property = "") + { + if (IsFrozen) + { + throw new Diagnostics.PhaseViolationException( + $"write {GetType().Name}.{property}", "construction of the master BuiltinRegistry", Name); + } + } } /// @@ -173,7 +230,7 @@ public record VariableSymbol : Symbol /// Uses 'internal set' because type resolution happens after symbol creation. /// Readers should prefer SemanticBinding.GetVariableType when available. /// - public SemanticType Type { get; internal set; } = SemanticType.Unknown; + public SemanticType Type { get; internal set { ThrowIfFrozen(); field = value; } } = SemanticType.Unknown; public bool IsParameter { get; init; } public bool IsConstant { get; init; } public bool IsStatic { get; init; } @@ -201,7 +258,7 @@ public record VariableSymbol : Symbol /// be folded. Used by IsImplicitConstantConversion to treat a const reference as a /// constant expression at every §10.2.11 site (#1460). /// - public System.Numerics.BigInteger? ConstantValue { get; internal set; } + public System.Numerics.BigInteger? ConstantValue { get; internal set { ThrowIfFrozen(); field = value; } } /// /// The compile-time-constant fact for a const symbol that arrived from ANOTHER module — @@ -212,7 +269,7 @@ public record VariableSymbol : Symbol /// symbol is created; ConstEligibility.IsCompileTimeConstant is the ONE reader that /// consults both (#1791, #1782). /// - public bool IsCompileTimeConstant { get; internal set; } + public bool IsCompileTimeConstant { get; internal set { ThrowIfFrozen(); field = value; } } public Parser.Ast.ParameterModifier ParameterModifier { get; init; } = Parser.Ast.ParameterModifier.None; @@ -234,7 +291,7 @@ public record VariableSymbol : Symbol /// Uses 'internal set' because separate property get/property set /// declarations for the same name are merged onto one symbol during name resolution. /// - public bool HasPropertyGetter { get; internal set; } + public bool HasPropertyGetter { get; internal set { ThrowIfFrozen(); field = value; } } /// /// True when a module-level property has a setter accessor @@ -245,7 +302,7 @@ public record VariableSymbol : Symbol /// Uses 'internal set' because separate property get/property set /// declarations for the same name are merged onto one symbol during name resolution. /// - public bool HasPropertySetter { get; internal set; } + public bool HasPropertySetter { get; internal set { ThrowIfFrozen(); field = value; } } public virtual bool Equals(VariableSymbol? other) => ReferenceEquals(this, other); public override int GetHashCode() => RuntimeHelpers.GetHashCode(this); @@ -256,8 +313,25 @@ public record VariableSymbol : Symbol /// public record FunctionSymbol : Symbol { - public List Parameters { get; init; } = new(); - public SemanticType ReturnType { get; internal set; } = SemanticType.Unknown; + public SymbolMemberList Parameters { get; init; } = new(); + + /// + /// Replaces one parameter — the only write a checker makes to once the + /// symbol is declared, routed here so a frozen (master) symbol refuses it (#2250). + /// + internal void ReplaceParameter(int index, ParameterSymbol parameter) + { + ThrowIfFrozen(nameof(Parameters)); + Parameters[index] = parameter; + } + + private protected override void FreezeMembers() + { + Parameters.Freeze(Name); + TypeParameters.Freeze(Name); + } + + public SemanticType ReturnType { get; internal set { ThrowIfFrozen(); field = value; } } = SemanticType.Unknown; public bool IsStatic { get; init; } public bool IsAbstract { get; init; } public bool IsVirtual { get; init; } @@ -268,24 +342,24 @@ public record FunctionSymbol : Symbol /// the pre-lowering validators read SemanticInfo.IsGenerator(FunctionDef); SynthesisAnalyzer /// uses this property when it has only the FunctionSymbol. /// - public bool IsGenerator { get; internal set; } + public bool IsGenerator { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Set by NameResolver when the function carries the '@must_use' decorator (#1022). The /// MustUseValidator warns (SPY0480) when a call to this function is discarded as a bare /// expression statement, even when the return type is not itself a Result/Optional carrier. /// - public bool IsMustUse { get; internal set; } + public bool IsMustUse { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Set by TypeChecker.Definitions.cs when the function is declared with 'async def'. /// CodeGen uses this to wrap return types in Task<T> and enable await expressions. /// Follows the same pattern as IsGenerator. /// - public bool IsAsync { get; internal set; } + public bool IsAsync { get; internal set { ThrowIfFrozen(); field = value; } } // Generic type parameters (for generic functions like def identity[T](value: T) -> T) - public List TypeParameters { get; init; } = new(); + public SymbolMemberList TypeParameters { get; init; } = new(); public bool IsGeneric => TypeParameters.Count > 0; // For .NET interop @@ -309,14 +383,14 @@ public record FunctionSymbol : Symbol /// @functools.lru_cache or @functools.cache. CodeGen reads this /// to emit a memoization wrapper backed by . /// - public bool IsCached { get; internal set; } + public bool IsCached { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Maxsize for the @lru_cache wrapper, or null for an unbounded cache /// (matching @cache and @lru_cache(maxsize=None)). Only meaningful /// when is true. /// - public int? CacheMaxSize { get; internal set; } + public int? CacheMaxSize { get; internal set { ThrowIfFrozen(); field = value; } } /// /// True when this method's receiver is a PEP 3101 format template whose replacement fields are @@ -361,13 +435,13 @@ public record TypeSymbol : Symbol /// /// Whether this type extends SourceGenerator (detected during inheritance resolution). /// - public bool IsSourceGenerator { get; internal set; } + public bool IsSourceGenerator { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Whether this type carries the '@must_use' decorator (#1022). The MustUseValidator warns /// (SPY0480) when an expression of this type is discarded as a bare expression statement. /// - public bool IsMustUse { get; internal set; } + public bool IsMustUse { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Whether this enum's members carry string values (#1284). A string-backed enum is emitted as @@ -381,19 +455,19 @@ public record TypeSymbol : Symbol /// /// Whether this type is a @dataclass. /// - public bool IsDataclass { get; internal set; } + public bool IsDataclass { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Dataclass configuration options (only set when IsDataclass is true). /// - public DataclassOptions? DataclassInfo { get; internal set; } + public DataclassOptions? DataclassInfo { get; internal set { ThrowIfFrozen(); field = value; } } /// /// Ordered list of dataclass fields (collected during semantic analysis). /// Includes inherited fields from parent @dataclass types (parent fields first). /// Only populated when IsDataclass is true. /// - public IReadOnlyList? DataclassFields { get; internal set; } + public IReadOnlyList? DataclassFields { get; internal set { ThrowIfFrozen(); field = value; } } /// /// The module path that defines this type (e.g., "animal" for a type imported from animal.spy). @@ -408,37 +482,37 @@ public record TypeSymbol : Symbol public string? DefiningFilePath { get; init; } // Generic type parameters - public List TypeParameters { get; init; } = new(); + public SymbolMemberList TypeParameters { get; init; } = new(); public bool IsGeneric => TypeParameters.Count > 0; // Members - public List Fields { get; init; } = new(); - public List Methods { get; init; } = new(); - public List Properties { get; init; } = new(); - public List Events { get; init; } = new(); + public SymbolMemberList Fields { get; init; } = new(); + public SymbolMemberList Methods { get; init; } = new(); + public SymbolMemberList Properties { get; init; } = new(); + public SymbolMemberList Events { get; init; } = new(); // Operator methods (dunder methods for operators) // Maps operator dunder names (e.g., "__add__", "__eq__") to lists of overloads - public Dictionary> OperatorMethods { get; init; } = new(); + public SymbolOverloadTable OperatorMethods { get; init; } = new(); // Protocol methods (non-operator dunders like __len__, __str__, __iter__) // Maps protocol dunder names to lists of overloads (usually just one, but allows flexibility) - public Dictionary> ProtocolMethods { get; init; } = new(); + public SymbolOverloadTable ProtocolMethods { get; init; } = new(); // Regular method overloads — maps method name to list of overloads // Only populated when a class has multiple methods with the same name - public Dictionary> MethodOverloads { get; init; } = new(); + public SymbolOverloadTable MethodOverloads { get; init; } = new(); // Constructors - tracks all __init__ overloads // Unlike Python (which allows only one __init__ that gets replaced), Sharpy supports // multiple __init__ methods that map to C# constructor overloads - public List Constructors { get; init; } = new(); + public SymbolMemberList Constructors { get; init; } = new(); // Union cases (only populated for TypeKind.Union) - public List UnionCases { get; init; } = new(); + public SymbolMemberList UnionCases { get; init; } = new(); // Nested types - public List NestedTypes { get; init; } = new(); + public SymbolMemberList NestedTypes { get; init; } = new(); /// /// Nested type aliases (type Id = int declared inside a class/struct/interface body). @@ -447,12 +521,12 @@ public record TypeSymbol : Symbol /// answer a qualified Outer.Id (#1729, R-I). The bare-inside form resolves through the /// host scope's symbol table instead — this list carries the qualified spelling only. /// - public List NestedTypeAliases { get; init; } = new(); + public SymbolMemberList NestedTypeAliases { get; init; } = new(); /// /// The enclosing type for nested types. Null for top-level types. /// - public TypeSymbol? DeclaringType { get; internal set; } + public TypeSymbol? DeclaringType { get; internal set { ThrowIfFrozen(); field = value; } } // Inheritance /// @@ -462,9 +536,9 @@ public record TypeSymbol : Symbol /// Uses 'internal set' because inheritance resolution happens after symbol creation. /// Readers should prefer SemanticBinding.GetBaseType when available. /// - public TypeSymbol? BaseType { get; internal set; } - public BaseTypeReference? BaseTypeRef { get; internal set; } - public List Interfaces { get; init; } = new(); + public TypeSymbol? BaseType { get; internal set { ThrowIfFrozen(); field = value; } } + public BaseTypeReference? BaseTypeRef { get; internal set { ThrowIfFrozen(); field = value; } } + public SymbolMemberList Interfaces { get; init; } = new(); /// /// Unresolved base class name from AST, used for deferred inheritance resolution @@ -494,7 +568,7 @@ public record TypeSymbol : Symbol /// alongside: a name list and an annotation list are two records of one fact, and the second /// one silently goes stale. /// - public List UnresolvedInterfaces { get; init; } = new(); + public SymbolMemberList UnresolvedInterfaces { get; init; } = new(); /// /// Dunder-synthesized interface rows restored from the symbol cache and not yet resolved to @@ -503,7 +577,7 @@ public record TypeSymbol : Symbol /// re-resolves it through the same rule on a warm build, /// alongside . Empty on the cold path. /// - public List UnresolvedSynthesizedInterfaces { get; init; } = new(); + public SymbolMemberList UnresolvedSynthesizedInterfaces { get; init; } = new(); /// /// Builds a MethodOverloads dictionary from a list of methods, excluding dunder methods. @@ -565,6 +639,25 @@ internal void DeriveMethodTables() RegisterDunderMethod(method); } + private protected override void FreezeMembers() + { + TypeParameters.Freeze(Name); + Fields.Freeze(Name); + Methods.Freeze(Name); + Properties.Freeze(Name); + Events.Freeze(Name); + OperatorMethods.Freeze(Name); + ProtocolMethods.Freeze(Name); + MethodOverloads.Freeze(Name); + Constructors.Freeze(Name); + UnionCases.Freeze(Name); + NestedTypes.Freeze(Name); + NestedTypeAliases.Freeze(Name); + Interfaces.Freeze(Name); + UnresolvedInterfaces.Freeze(Name); + UnresolvedSynthesizedInterfaces.Freeze(Name); + } + public virtual bool Equals(TypeSymbol? other) => ReferenceEquals(this, other); public override int GetHashCode() => RuntimeHelpers.GetHashCode(this); } diff --git a/src/Sharpy.Compiler/Semantic/SymbolMemberCollections.cs b/src/Sharpy.Compiler/Semantic/SymbolMemberCollections.cs new file mode 100644 index 000000000..d4b7cf9a9 --- /dev/null +++ b/src/Sharpy.Compiler/Semantic/SymbolMemberCollections.cs @@ -0,0 +1,194 @@ +using System.Collections; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace Sharpy.Compiler.Semantic; + +/// +/// A symbol's member list (a type's fields, methods, properties, interfaces, …): a list that refuses +/// every write once the symbol owning it is frozen (#2250). +/// +/// +/// +/// A master is shared by every analysis on a +/// CompilerApi, and its symbols are frozen when its construction completes +/// (). Freezing the settable properties alone left the member LISTS open: +/// a mixed auto/setter property in a class str: nested in a block added a backing field to the +/// master str, and every later document on that CompilerApi saw it. A frozen list throws +/// from every mutator (Add, Insert, +/// the indexer setter, Remove, RemoveAt, Clear, ). +/// +/// +/// A with copy of a symbol shares its lists (a record copy copies the reference), so a write +/// through a copy of a master symbol is a write to the master and is refused too. +/// +/// +/// A converts implicitly — the list is wrapped, not copied, so a declaration +/// site that builds a list and assigns it keeps one list, exactly as before. +/// +/// +public sealed class SymbolMemberList : Collection +{ + private string? _frozenOwner; + + public SymbolMemberList() + : base(new List()) + { + } + + public SymbolMemberList(IEnumerable items) + : base(new List(items)) + { + } + + private SymbolMemberList(List items) + : base(items) + { + } + + /// Wraps (no copy) as a symbol member list. + [return: NotNullIfNotNull(nameof(items))] + public static implicit operator SymbolMemberList?(List? items) + => items is null ? null : new SymbolMemberList(items); + + /// True once the owning symbol is frozen: every mutator throws. + internal bool IsFrozen => _frozenOwner is not null; + + /// Freezes this list as a member of the frozen symbol . + internal void Freeze(string ownerName) => _frozenOwner = ownerName; + + private List Backing => (List)Items; + + public void AddRange(IEnumerable items) + { + foreach (var item in items) + Add(item); + } + + public T? Find(Predicate match) => Backing.Find(match); + + public int FindIndex(Predicate match) => Backing.FindIndex(match); + + public bool Exists(Predicate match) => Backing.Exists(match); + + protected override void InsertItem(int index, T item) + { + ThrowIfFrozen("add to"); + base.InsertItem(index, item); + } + + protected override void SetItem(int index, T item) + { + ThrowIfFrozen("replace an element of"); + base.SetItem(index, item); + } + + protected override void RemoveItem(int index) + { + ThrowIfFrozen("remove from"); + base.RemoveItem(index); + } + + protected override void ClearItems() + { + ThrowIfFrozen("clear"); + base.ClearItems(); + } + + private void ThrowIfFrozen(string operation) + { + if (_frozenOwner is { } owner) + { + throw new Diagnostics.PhaseViolationException( + $"{operation} a member list of a frozen symbol", "construction of the master BuiltinRegistry", owner); + } + } +} + +/// +/// A symbol's name-keyed overload table (, +/// , ): a dictionary of +/// that refuses every write — to the table or to any overload list +/// in it — once the symbol owning it is frozen (#2250). See . +/// +public sealed class SymbolOverloadTable : IReadOnlyDictionary> +{ + private readonly Dictionary> _table = new(); + private string? _frozenOwner; + + /// Wraps each overload list of (the lists are not copied). + [return: NotNullIfNotNull(nameof(table))] + public static implicit operator SymbolOverloadTable?(Dictionary>? table) + { + if (table is null) + return null; + + var result = new SymbolOverloadTable(); + foreach (var (name, overloads) in table) + result._table[name] = overloads; + return result; + } + + /// True once the owning symbol is frozen: every mutator throws. + internal bool IsFrozen => _frozenOwner is not null; + + /// Freezes the table and every overload list in it. + internal void Freeze(string ownerName) + { + _frozenOwner = ownerName; + foreach (var overloads in _table.Values) + overloads.Freeze(ownerName); + } + + public SymbolMemberList this[string key] + { + get => _table[key]; + set + { + ThrowIfFrozen("set an entry of"); + _table[key] = value; + } + } + + public int Count => _table.Count; + + public IEnumerable Keys => _table.Keys; + + public IEnumerable> Values => _table.Values; + + public bool ContainsKey(string key) => _table.ContainsKey(key); + + public bool TryGetValue(string key, [MaybeNullWhen(false)] out SymbolMemberList value) + => _table.TryGetValue(key, out value); + + public bool TryAdd(string key, SymbolMemberList value) + { + ThrowIfFrozen("add an entry to"); + return _table.TryAdd(key, value); + } + + public void Add(string key, SymbolMemberList value) + { + ThrowIfFrozen("add an entry to"); + _table.Add(key, value); + } + + public bool Remove(string key) + { + ThrowIfFrozen("remove an entry from"); + return _table.Remove(key); + } + + public IEnumerator>> GetEnumerator() => _table.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private void ThrowIfFrozen(string operation) + { + if (_frozenOwner is { } owner) + { + throw new Diagnostics.PhaseViolationException( + $"{operation} an overload table of a frozen symbol", "construction of the master BuiltinRegistry", owner); + } + } +} diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs index 34b9c1a10..d60e8cf4a 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Definitions.cs @@ -20,12 +20,8 @@ internal partial class TypeChecker /// private void ResolveModuleFunctionSignature(FunctionDef functionDef) { - // For overloaded module-level functions, the symbol table only holds the - // first overload, so resolve the specific symbol by declaration line. - var overloads = _symbolTable.LookupFunctionOverloads(functionDef.Name); - var functionSymbol = overloads is { Count: > 1 } - ? overloads.FirstOrDefault(o => o.DeclarationLine == functionDef.LineStart) - : _symbolTable.LookupFunction(functionDef.Name); + // The symbol declared for THIS def, never one found by name alone (#2250). + var functionSymbol = LookupFunctionSymbol(functionDef); if (functionSymbol == null) return; @@ -116,7 +112,7 @@ private void ResolveFunctionSignatureInto(FunctionSymbol functionSymbol, Functio } if (i < functionSymbol.Parameters.Count) { - functionSymbol.Parameters[i] = functionSymbol.Parameters[i] with { Type = paramType }; + functionSymbol.ReplaceParameter(i, functionSymbol.Parameters[i] with { Type = paramType }); } } @@ -161,10 +157,13 @@ private void CheckFunction(FunctionDef functionDef) { _logger.LogDebug($"Type checking function: {functionDef.Name}"); - // Nested function: if we're inside a function (not at module level), - // register the nested function symbol in the enclosing scope so it can be called by name. - // NameResolver only handles module-level declarations, so nested functions need registration here. - if (_currentFunctionReturnType != null) + // A def NameResolver did not declare declares its OWN symbol here, in the scope it appears in. + // NameResolver walks only the direct members of a module or type body, so a def nested in a + // function — or inside a block (try/if/for/while/with/match) at module or class level — has no + // symbol yet. Without this, the lookup below found whatever the name already meant: for a + // builtin's name inside a `try:` body that was the MASTER registry's symbol, which the + // signature writes then retyped for every later document on the same CompilerApi (#2250). + if (_currentFunctionReturnType != null || _controlFlowDepth > 0) { var existingSymbol = _symbolTable.Lookup(functionDef.Name, searchParents: false); if (existingSymbol == null) @@ -391,14 +390,96 @@ private void CheckFunction(FunctionDef functionDef) // For top-level/nested functions, look up from symbol table. // When the function is overloaded, the symbol table only holds the first // overload, so resolve the specific symbol by declaration line. + // + // A name lookup answers with whatever the name MEANS here, which is not necessarily the + // symbol this definition declared: when nothing declared one (a def NameResolver does not + // walk), it is the enclosing binding — for a builtin's name, the master BuiltinRegistry's + // symbol, shared by every analysis on the CompilerApi (#2250). Every caller writes the + // signature onto what this returns, so it returns only the symbol declared AT this def. var overloads = _symbolTable.LookupFunctionOverloads(functionDef.Name); - if (overloads is { Count: > 1 }) - { - return overloads.FirstOrDefault(o => o.DeclarationLine == functionDef.LineStart); - } - return _symbolTable.LookupFunction(functionDef.Name); + var candidate = overloads is { Count: > 1 } + ? overloads.FirstOrDefault(o => o.DeclarationLine == functionDef.LineStart) + : _symbolTable.LookupFunction(functionDef.Name); + return candidate is not null && IsDeclaredBy(candidate, functionDef) ? candidate : null; } + /// + /// The symbol the generator/async/cache metadata passes mark, when the def is not a method of the + /// current class: the symbol declared for this def () — never a + /// same-named symbol it did not declare (#2250). A constructor is not a target: these passes never + /// reached one by name, and 's constructor arm must not make them. + /// + private FunctionSymbol? LookupMetadataTarget(FunctionDef functionDef) + => _currentClass != null && functionDef.Name == DunderNames.Init ? null : LookupFunctionSymbol(functionDef); + + /// + /// True when is the symbol declared for + /// in this compilation — the one a signature write may target. Both declaring sites + /// (NameResolver.ResolveFunctionDeclaration and 's local + /// declaration) stamp the definition's span; a builtin, an imported symbol or an enclosing + /// definition of the same name carries another span or none (#2250). A definition built without + /// a span (a synthesized AST) is matched by its start position, which both sites also stamp. + /// + private bool IsDeclaredBy(Symbol symbol, Statement definition) + => !_symbolTable.BuiltinRegistry.IsBuiltinSymbol(symbol) && symbol.IsDeclaredAt(definition); + + /// + /// The symbol declared FOR this type definition (class, struct, interface, union, delegate) — + /// the one its checker writes fields, properties, constructors, dataclass facts and member types + /// onto — or null when nothing declared one (#2250). + /// + /// + /// + /// NameResolver declares the type definitions of a module body and of a type body (nested types, + /// which the host's checker re-registers in the host's scope). A type definition anywhere else — + /// in a function body, or inside a block (if/for/while/try/with/ + /// match) — has no symbol: C# has no local types, so it is refused + /// (), never emitted. A bare name lookup there answered + /// with whatever the name already meant — the master BuiltinRegistry's str, shared by every + /// analysis on a CompilerApi, or an enclosing user type of the same name — and the checker then + /// wrote that symbol: a later document on the same CompilerApi lost its SPY0203 on + /// s._size, and a function-local class Foo gave the module's Foo a + /// _size field. + /// + /// + /// The same rule as : the declaration span identifies the + /// symbol, and a builtin never qualifies. A second definition of a name in one scope (refused by + /// NameResolver as a duplicate) has no symbol of its own either, so it no longer writes the first. + /// + /// + private TypeSymbol? LookupTypeDefinitionSymbol(Statement typeDefinition, string name) + => _symbolTable.Lookup(name) is TypeSymbol candidate && IsDeclaredBy(candidate, typeDefinition) + ? candidate + : null; + + /// + /// The refusal for a type definition with no symbol of its own + /// (): the SPY0202 "symbol not found" a + /// function-local class already got when its name was unbound, now reached wherever the definition + /// has no declaration instead of binding to a same-named symbol (#2250). A duplicate definition + /// already carries NameResolver's refusal, so it gets no second diagnostic. + /// + private void ReportUndeclaredTypeDefinition(Statement typeDefinition, string kind, string name) + { + if (IsDuplicateTypeDefinition(typeDefinition, name)) + return; + + AddError($"{kind} symbol for '{name}' not found", typeDefinition.LineStart, typeDefinition.ColumnStart, + code: DiagnosticCodes.Semantic.UndefinedType, span: typeDefinition.Span); + } + + /// + /// True when another definition of was declared in the CURRENT scope at a + /// declaring position (a module or type body, outside any function or block): NameResolver refused + /// this one as a duplicate, and a "not found" beside it would be a cascade on the same mistake. + /// + private bool IsDuplicateTypeDefinition(Statement typeDefinition, string name) + => _currentFunctionReturnType == null + && _controlFlowDepth == 0 + && _symbolTable.Lookup(name, searchParents: false) is { } existing + && !_symbolTable.BuiltinRegistry.IsBuiltinSymbol(existing) + && !IsDeclaredBy(existing, typeDefinition); + /// /// Resolve the return type for a function definition from its type annotation. /// @@ -762,7 +843,7 @@ private void RegisterFunctionParameters(FunctionDef functionDef, FunctionSymbol? // Update the function symbol's parameter type if (functionSymbol != null && i < functionSymbol.Parameters.Count) { - functionSymbol.Parameters[i] = functionSymbol.Parameters[i] with { Type = paramType }; + functionSymbol.ReplaceParameter(i, functionSymbol.Parameters[i] with { Type = paramType }); } if (param.DefaultValue != null) @@ -844,11 +925,10 @@ private void ProcessGeneratorMetadata(FunctionDef functionDef, FunctionSymbol? f // so the symbol table doesn't contain the method. The Methods list has the // updatedSymbol (from the return type update), which is also the same // reference stored in ProtocolMethods/OperatorMethods. - // For top-level functions, check the overload list first (disambiguate by line), - // then fall back to the symbol table for non-overloaded functions. + // Otherwise the symbol declared for this def (LookupMetadataTarget) — never a same-named + // symbol the def did not declare, such as a master builtin (#2250). var currentSymbol = _currentClass?.Methods.FirstOrDefault(m => m.Name == functionDef.Name && m.DeclarationLine == functionDef.LineStart) - ?? _symbolTable.LookupFunctionOverloads(functionDef.Name)?.FirstOrDefault(o => o.DeclarationLine == functionDef.LineStart) - ?? _symbolTable.Lookup(functionDef.Name) as FunctionSymbol; + ?? LookupMetadataTarget(functionDef); Debug.Assert(currentSymbol != null, $"Generator function '{functionDef.Name}' not found in Methods list or SymbolTable"); if (currentSymbol != null) { @@ -887,8 +967,7 @@ private void ProcessAsyncMetadata(FunctionDef functionDef, FunctionSymbol? funct return; var asyncSymbol = _currentClass?.Methods.FirstOrDefault(m => m.Name == functionDef.Name && m.DeclarationLine == functionDef.LineStart) - ?? _symbolTable.LookupFunctionOverloads(functionDef.Name)?.FirstOrDefault(o => o.DeclarationLine == functionDef.LineStart) - ?? _symbolTable.Lookup(functionDef.Name) as FunctionSymbol; + ?? LookupMetadataTarget(functionDef); if (asyncSymbol == null) return; @@ -927,8 +1006,7 @@ private void ProcessCacheMetadata(FunctionDef functionDef, FunctionSymbol? funct // because the TypeChecker class scope is already popped at this point. var currentSymbol = _currentClass?.Methods.FirstOrDefault(m => m.Name == functionDef.Name && m.DeclarationLine == functionDef.LineStart) - ?? _symbolTable.LookupFunctionOverloads(functionDef.Name)?.FirstOrDefault(o => o.DeclarationLine == functionDef.LineStart) - ?? _symbolTable.Lookup(functionDef.Name) as FunctionSymbol; + ?? LookupMetadataTarget(functionDef); if (currentSymbol == null) return; @@ -1149,12 +1227,11 @@ private void CheckClass(ClassDef classDef) { _logger.LogDebug($"Type checking class: {classDef.Name}"); - // Look up the class symbol - var classSymbol = _symbolTable.Lookup(classDef.Name) as TypeSymbol; + // The symbol declared FOR this definition — never a builtin's or an enclosing type's (#2250). + var classSymbol = LookupTypeDefinitionSymbol(classDef, classDef.Name); if (classSymbol == null) { - AddError($"Class symbol for '{classDef.Name}' not found", classDef.LineStart, classDef.ColumnStart, code: DiagnosticCodes.Semantic.UndefinedType, - span: classDef.Span); + ReportUndeclaredTypeDefinition(classDef, "Class", classDef.Name); return; } @@ -1447,12 +1524,11 @@ private void CheckStruct(StructDef structDef) { _logger.LogDebug($"Type checking struct: {structDef.Name}"); - // Look up the struct symbol - var structSymbol = _symbolTable.Lookup(structDef.Name) as TypeSymbol; + // The symbol declared FOR this definition — never a builtin's or an enclosing type's (#2250). + var structSymbol = LookupTypeDefinitionSymbol(structDef, structDef.Name); if (structSymbol == null) { - AddError($"Struct symbol for '{structDef.Name}' not found", structDef.LineStart, structDef.ColumnStart, code: DiagnosticCodes.Semantic.UndefinedType, - span: structDef.Span); + ReportUndeclaredTypeDefinition(structDef, "Struct", structDef.Name); return; } @@ -1554,12 +1630,11 @@ private void CheckInterface(InterfaceDef interfaceDef) { _logger.LogDebug($"Type checking interface: {interfaceDef.Name}"); - // Look up the interface symbol - var interfaceSymbol = _symbolTable.Lookup(interfaceDef.Name) as TypeSymbol; + // The symbol declared FOR this definition — never a builtin's or an enclosing type's (#2250). + var interfaceSymbol = LookupTypeDefinitionSymbol(interfaceDef, interfaceDef.Name); if (interfaceSymbol == null) { - AddError($"Interface symbol for '{interfaceDef.Name}' not found", interfaceDef.LineStart, interfaceDef.ColumnStart, code: DiagnosticCodes.Semantic.UndefinedType, - span: interfaceDef.Span); + ReportUndeclaredTypeDefinition(interfaceDef, "Interface", interfaceDef.Name); return; } @@ -1788,11 +1863,11 @@ private void CheckUnion(UnionDef unionDef) { _logger.LogDebug($"Type checking union: {unionDef.Name}"); - var unionSymbol = _symbolTable.Lookup(unionDef.Name) as TypeSymbol; + // The symbol declared FOR this definition — never a builtin's or an enclosing type's (#2250). + var unionSymbol = LookupTypeDefinitionSymbol(unionDef, unionDef.Name); if (unionSymbol == null) { - AddError($"Union symbol for '{unionDef.Name}' not found", unionDef.LineStart, unionDef.ColumnStart, - code: DiagnosticCodes.Semantic.UndefinedType, span: unionDef.Span); + ReportUndeclaredTypeDefinition(unionDef, "Union", unionDef.Name); return; } @@ -1890,11 +1965,11 @@ private void CheckDelegate(DelegateDef delegateDef) { _logger.LogDebug($"Type checking delegate: {delegateDef.Name}"); - var delegateSymbol = _symbolTable.Lookup(delegateDef.Name) as TypeSymbol; + // The symbol declared FOR this definition — never a builtin's or an enclosing type's (#2250). + var delegateSymbol = LookupTypeDefinitionSymbol(delegateDef, delegateDef.Name); if (delegateSymbol == null) { - AddError($"Delegate symbol for '{delegateDef.Name}' not found", delegateDef.LineStart, delegateDef.ColumnStart, - code: DiagnosticCodes.Semantic.UndefinedType, span: delegateDef.Span); + ReportUndeclaredTypeDefinition(delegateDef, "Delegate", delegateDef.Name); return; } diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.Overloads.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.Overloads.cs index 1dce1a272..95b276f5f 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.Overloads.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.Overloads.cs @@ -188,7 +188,7 @@ public void Deconstruct( /// arguments match the TRAILING parameters. Only the operator-dunder path sets this; see /// for why the method-call paths must not. internal record OverloadResolutionContext( - List Candidates, + IReadOnlyList Candidates, int TotalArgCount, List ArgTypes, bool SkipSelfParam = false, @@ -2570,7 +2570,7 @@ private bool IsModuleQualifiedNamePath(MemberAccess memberAccess) /// The overload set, plus the receiver's type-argument substitution — a method reached /// through list[int] denotes signatures with T already replaced by int, which /// is what a target type is compared against. - private (List Overloads, Func Substitute)? + private (IReadOnlyList Overloads, Func Substitute)? ResolveReferencedCallableOverloads(Expression reference) { static SemanticType Identity(SemanticType t) => t; diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.cs index 1f1e372c7..92332246c 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Calls.cs @@ -1660,7 +1660,7 @@ private static bool IsBuiltinsModule(ModuleSymbol moduleSymbol) => /// /// The overload set a receiver's type declares under . /// - private List? LookupInstanceMethodOverloads(SemanticType? receiverType, string memberName) + private IReadOnlyList? LookupInstanceMethodOverloads(SemanticType? receiverType, string memberName) { if (receiverType == null) return null; @@ -1749,16 +1749,15 @@ private bool IsMoreSpecificType(SemanticType a, SemanticType b) var clrTypeName = func.Parameters[paramIdx].ClrTypeName; if (!string.IsNullOrEmpty(clrTypeName)) { - var clrType = Type.GetType(clrTypeName); + // Resolved against this compilation's visibility, never the process's loaded set + // (#2250): the AQN first, then — as before — the bare namespace-qualified name in + // any visible assembly, for an AQN whose assembly the visibility names differently. + var clrType = Visibility.ResolveType(clrTypeName!); if (clrType == null) { - // Assembly.GetType expects just the namespace-qualified name, - // not the full AQN — strip the assembly qualifier. var commaIdx = clrTypeName!.IndexOf(',', StringComparison.Ordinal); - var typeNameOnly = commaIdx >= 0 ? clrTypeName[..commaIdx] : clrTypeName; - clrType = AppDomain.CurrentDomain.GetAssemblies() - .Select(a => a.GetType(typeNameOnly)) - .FirstOrDefault(t => t != null); + if (commaIdx >= 0) + clrType = Visibility.ResolveType(clrTypeName[..commaIdx]); } if (clrType != null) { @@ -2159,7 +2158,7 @@ private void RecordMinMaxTypeArguments(FunctionCall call, string calleeName, Lis /// Finds all overloads for a method name walking the type hierarchy. /// Returns null if no overloads are found. /// - private List? FindMethodOverloadsInHierarchy(TypeSymbol type, string methodName) + private IReadOnlyList? FindMethodOverloadsInHierarchy(TypeSymbol type, string methodName) => FindOverloadsInHierarchy(type, methodName, static (t, n) => t.MethodOverloads.TryGetValue(n, out var overloads) && overloads.Count > 0 ? overloads @@ -2175,7 +2174,7 @@ private void RecordMinMaxTypeArguments(FunctionCall call, string calleeName, Lis /// therefore blind to every dunder overload set, which is how obj(args) on a class with /// two __call__ declarations resolved to whichever was written first (#1672). /// - private List? FindDunderOverloadsInHierarchy(TypeSymbol type, string dunderName) + private IReadOnlyList? FindDunderOverloadsInHierarchy(TypeSymbol type, string dunderName) => FindOverloadsInHierarchy(type, dunderName, static (t, n) => t.ProtocolMethods.TryGetValue(n, out var protocolOverloads) && protocolOverloads.Count > 0 ? protocolOverloads @@ -2188,8 +2187,8 @@ private void RecordMinMaxTypeArguments(FunctionCall call, string calleeName, Lis /// chain, then its interfaces (the last handles interface-typed variables and interface methods /// not reachable through the base chain, #364). Only the per-type dictionary differs. /// - private List? FindOverloadsInHierarchy( - TypeSymbol type, string name, Func?> lookup) + private IReadOnlyList? FindOverloadsInHierarchy( + TypeSymbol type, string name, Func?> lookup) { if (lookup(type, name) is { } own) return own; diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Lambdas.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Lambdas.cs index 7b51caa66..1a6194557 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Lambdas.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.Lambdas.cs @@ -675,7 +675,7 @@ private void InferParamTypesFromCall(FunctionCall call, Dictionary // Resolve the function being called to get its parameter types. Dispatched on the canonical // (paren-stripped) callee so a placeholder under `(add)(5, _)` infers the same as `add(5, _)` // (#1170). - List? funcParams = null; + IReadOnlyList? funcParams = null; var callee = UnwrapParenthesized(call.Function); if (callee is Identifier funcId) diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.cs index a2f1fcd52..3c91b3773 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Access.cs @@ -1515,7 +1515,7 @@ private bool IsConstructedClrGenericReceiver(SemanticType type) /// private TypeSymbol? GenericDefinitionOf(GenericType generic) => generic.GenericDefinition - ?? (Discovery.ClrTypeHelper.ResolveOriginDefinition(generic) is { } originDefinition + ?? (Discovery.ClrTypeHelper.ResolveOriginDefinition(Visibility, generic) is { } originDefinition ? _bclGenericMethodBridge.GetOrCreateClrDefinitionSymbol(originDefinition) : null); @@ -3131,7 +3131,7 @@ private IndexAccessLowering ComputeIndexAccessLowering( if (viewType is TupleType && TryGetConstantIntIndex(index, out _)) return IndexAccessLowering.TupleItem; - if (index is TupleLiteral && Discovery.ClrTypeHelper.HasParamsIndexer(viewType)) + if (index is TupleLiteral && Discovery.ClrTypeHelper.HasParamsIndexer(Visibility, viewType)) return IndexAccessLowering.ParamsSpread; if (viewType == SemanticType.Str) diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Literals.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Literals.cs index 1ff9f7b7c..a03d74dbe 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Literals.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Expressions.Literals.cs @@ -1037,7 +1037,7 @@ private SemanticType CheckSliceAccess(SliceAccess sliceAccess) }; if (receiverSymbol != null) { - List? getItemMethods = null; + SymbolMemberList? getItemMethods = null; receiverSymbol.OperatorMethods.TryGetValue(DunderNames.GetItem, out getItemMethods); if (getItemMethods == null) receiverSymbol.ProtocolMethods.TryGetValue(DunderNames.GetItem, out getItemMethods); diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.Utilities.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.Utilities.cs index cdfa4d8b2..ea902125b 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.Utilities.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.Utilities.cs @@ -1972,28 +1972,17 @@ private SemanticType ProjectClrCharSequence(Expression producer, SemanticType ma else if (candidateClr != null && !candidateClr.IsGenericTypeDefinition) { // ClrType is non-generic (e.g., IEnumerable instead of IEnumerable). - // Search loaded assemblies for the generic version with matching arity. + // Find the generic version with matching arity in THIS compilation's + // visibility (#2250): the same-namespace twin first, then any visible + // generic definition of the display name and arity. var arity = gt.TypeArguments.Count; var genericName = candidateClr.Name.Contains('`', StringComparison.Ordinal) ? candidateClr.Name : candidateClr.Name + "`" + arity; - foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies()) - { - try - { - var match = asm.GetType(candidateClr.Namespace + "." + genericName) - ?? asm.GetTypes().FirstOrDefault(t => - t.IsGenericTypeDefinition - && Shared.ClrNameHelper.StripArity(t.Name) == gt.Name - && t.GetGenericArguments().Length == arity); - if (match != null) - { - openType = match; - break; - } - } - catch { } - } + var match = Visibility.ResolveType(candidateClr.Namespace + "." + genericName) + ?? Visibility.GenericDefinitionsNamed(gt.Name, arity).FirstOrDefault(); + if (match is { IsGenericTypeDefinition: true }) + openType = match; } } if (openType == null || !openType.IsGenericTypeDefinition) @@ -2243,7 +2232,7 @@ private ElementAdmissionResult AdmitTupleElements( /// private SemanticType SubstituteTypeParameters( SemanticType type, - List typeParams, + IReadOnlyList typeParams, List typeArgs, bool substituteNamedUserTypes = false) { diff --git a/src/Sharpy.Compiler/Semantic/TypeChecker.cs b/src/Sharpy.Compiler/Semantic/TypeChecker.cs index 5dd6e5cbf..3eafbb24f 100644 --- a/src/Sharpy.Compiler/Semantic/TypeChecker.cs +++ b/src/Sharpy.Compiler/Semantic/TypeChecker.cs @@ -440,6 +440,8 @@ public SemanticContext CreateSemanticContext() context.Features = Features; // Thread the project's references so the SPY0495 absence proof can consult them (#1492). context.ReferenceClosure = ReferenceClosure; + // Thread the compilation's CLR visibility so validators resolve CLR names as the checker does (#2250). + context.Visibility = Visibility; return context; } @@ -586,7 +588,27 @@ public string? CurrentFilePath /// When set, backtick-escaped identifiers that fail symbol-table lookup are resolved against /// the registry's known .NET namespaces lazily, without requiring an explicit import. /// - internal ModuleRegistry? ModuleRegistry { get; set; } + internal ModuleRegistry? ModuleRegistry + { + get => _moduleRegistry; + set + { + _moduleRegistry = value; + // The inference service resolves recorded CLR names (a generic's ClrOriginTypeName) + // against the same visibility the checker does (#2250). + _typeInference.Visibility = Visibility; + } + } + + private ModuleRegistry? _moduleRegistry; + + /// + /// The CLR types and namespaces this compilation may see: its registry's + /// , or the framework set alone when it has no registry + /// (#2250). Every CLR-name question the checker asks goes through it. + /// + internal Discovery.AssemblyVisibility Visibility + => _moduleRegistry?.Visibility ?? Discovery.AssemblyVisibility.FrameworkOnly; /// /// Check for cancellation periodically in tight loops. diff --git a/src/Sharpy.Compiler/Semantic/TypeInferenceService.cs b/src/Sharpy.Compiler/Semantic/TypeInferenceService.cs index a576d4230..ca6cb849c 100644 --- a/src/Sharpy.Compiler/Semantic/TypeInferenceService.cs +++ b/src/Sharpy.Compiler/Semantic/TypeInferenceService.cs @@ -61,6 +61,13 @@ internal class TypeInferenceService /// internal Func? OperandAssignability { get; set; } + /// + /// The CLR types this compilation may see — a generic recorded only by its CLR origin name + /// resolves against it, never against the process's loaded set (#2250). Set by the owning + /// TypeChecker from its registry. + /// + internal Discovery.AssemblyVisibility Visibility { get; set; } = Discovery.AssemblyVisibility.FrameworkOnly; + // Caches for performance (not thread-safe) private readonly Dictionary<(SemanticType, BinaryOperator, SemanticType), SemanticType?> _binaryOpCache = new(); private readonly Dictionary<(UnaryOperator, SemanticType), SemanticType?> _unaryOpCache = new(); @@ -774,7 +781,7 @@ private static bool HasEqualityDunderAdmittingNone(TypeSymbol symbol) /// those parameters open, and an open position is a wildcard to the operand check. /// private FunctionSymbol? FindBestOverload( - List candidates, SemanticType argumentType, SemanticType? receiver = null) + IReadOnlyList candidates, SemanticType argumentType, SemanticType? receiver = null) { if (candidates.Count == 0) return null; @@ -1702,7 +1709,7 @@ private static bool OverridesObjectEquals(Type clrType) /// (the single-method chain walker only ever returns one candidate) — currently just /// __getitem__ in 's host-view arm (#1859). /// - private static List? FindOverloadsInChain(TypeSymbol? symbol, string dunderName) + private static IReadOnlyList? FindOverloadsInChain(TypeSymbol? symbol, string dunderName) { for (var current = symbol; current != null; current = current.BaseType) { @@ -2037,7 +2044,7 @@ internal static SemanticType ApplyIntegerFloor(SemanticType type) } private Type? TryConstructClosedGeneric(GenericType generic) - => Discovery.ClrTypeHelper.TryConstructClosedGeneric(generic, GetClrType); + => Discovery.ClrTypeHelper.TryConstructClosedGeneric(Visibility, generic, GetClrType); /// /// Recursively unwraps (C# nullable interop) wrappers for diff --git a/src/Sharpy.Compiler/Semantic/Validation/DecoratorValidator.BracketAttributes.cs b/src/Sharpy.Compiler/Semantic/Validation/DecoratorValidator.BracketAttributes.cs index 88a412487..5e9a04db5 100644 --- a/src/Sharpy.Compiler/Semantic/Validation/DecoratorValidator.BracketAttributes.cs +++ b/src/Sharpy.Compiler/Semantic/Validation/DecoratorValidator.BracketAttributes.cs @@ -20,9 +20,13 @@ internal partial class DecoratorValidator /// The attribute resolver, held per VALIDATOR rather than reached statically, so its absence /// verdicts die with this compilation instead of outliving the facts they summarize (#1493). /// ValidationPipelineFactory.CreateDefault builds a fresh DecoratorValidator per compilation, - /// which is what makes instance lifetime the right scope. + /// which is what makes instance lifetime the right scope. Built on first use over the + /// compilation's (#2250). /// - private readonly ClrAttributeResolver _attributeResolver = new(); + private ClrAttributeResolver? _attributeResolverInstance; + + private ClrAttributeResolver AttributeResolver + => _attributeResolverInstance ??= new ClrAttributeResolver(Context.Visibility); /// /// Module-name prefixes the emitter treats as .NET framework namespaces — the only imports @@ -142,16 +146,16 @@ private void ValidateBracketAttributeResolves(Decorator decorator) // The proof now includes the project's own references (#1492), so a type living only in a // .spyproj resolves here instead of being refused by a proof that never looked // where it lives. - _attributeResolver.SetReferenceClosure(Context.ReferenceClosure); + AttributeResolver.SetReferenceClosure(Context.ReferenceClosure); - if (_attributeResolver.ResolvesToClrType(mangled, _importedClrNamespaces)) + if (AttributeResolver.ResolvesToClrType(mangled, _importedClrNamespaces)) { // Resolves as a CLR attribute. If it resolves ONLY through one of the file's imported // namespaces (no bare or always-in-scope spelling does), record that namespace so // UnusedImportValidator counts the import that brings it into scope as used (#1429). A // name reachable via an always-in-scope namespace records nothing, so a redundant import // there is still (correctly) reported unused. - var requiredImport = _attributeResolver.RequiredImportNamespace(mangled, _importedClrNamespaces); + var requiredImport = AttributeResolver.RequiredImportNamespace(mangled, _importedClrNamespaces); if (requiredImport != null) Context.SemanticInfo.SetBracketAttributeResolvedNamespace(decorator, requiredImport); return; @@ -168,7 +172,7 @@ private void ValidateBracketAttributeResolves(Decorator decorator) // #1146 leak — CS0246 behind SPY0908 instead of a clean diagnostic — for every project // with any reference at all. The negative cell in BracketAttributeResolutionTests pins // this distinction (#1492). - if (_attributeResolver.HasUnprobedReferences) + if (AttributeResolver.HasUnprobedReferences) return; var attempted = $"'{mangled}' or '{mangled}Attribute'"; diff --git a/src/Sharpy.Compiler/Semantic/Validation/ProtocolValidator.cs b/src/Sharpy.Compiler/Semantic/Validation/ProtocolValidator.cs index aa981da20..67521ca6a 100644 --- a/src/Sharpy.Compiler/Semantic/Validation/ProtocolValidator.cs +++ b/src/Sharpy.Compiler/Semantic/Validation/ProtocolValidator.cs @@ -269,7 +269,7 @@ private bool HasProtocol(SemanticType type, string dunderName) => Membership.Has(type, dunderName); private ProtocolMembership Membership - => _membership ??= new ProtocolMembership(Context.SymbolTable, Context.Builtins); + => _membership ??= new ProtocolMembership(Context.SymbolTable, Context.Builtins, Context.Visibility); private ProtocolMembership? _membership; } diff --git a/src/Sharpy.Compiler/Semantic/Validation/SemanticContext.cs b/src/Sharpy.Compiler/Semantic/Validation/SemanticContext.cs index 4753f5952..7cdd86e17 100644 --- a/src/Sharpy.Compiler/Semantic/Validation/SemanticContext.cs +++ b/src/Sharpy.Compiler/Semantic/Validation/SemanticContext.cs @@ -90,6 +90,13 @@ internal class SemanticContext /// internal Discovery.ReferenceClosure ReferenceClosure { get; set; } = Discovery.ReferenceClosure.Empty; + /// + /// The CLR types and namespaces this compilation may see — the framework set plus its registry's + /// references, never another compilation's loads (#2250). + /// for a compilation with no registry. + /// + internal Discovery.AssemblyVisibility Visibility { get; set; } = Discovery.AssemblyVisibility.FrameworkOnly; + // Configuration public bool ContinueAfterErrors { get; set; } = true; public int MaxErrors { get; set; } = 100; diff --git a/src/Sharpy.Lsp.Tests/Conformance/CodeActionShapeSweepTests.cs b/src/Sharpy.Lsp.Tests/Conformance/CodeActionShapeSweepTests.cs index 9394be5e1..0648c236d 100644 --- a/src/Sharpy.Lsp.Tests/Conformance/CodeActionShapeSweepTests.cs +++ b/src/Sharpy.Lsp.Tests/Conformance/CodeActionShapeSweepTests.cs @@ -1792,23 +1792,28 @@ internal static bool JudgesDiagnostics(string stem, string twin, string requestK => full || SweepCorpus.StableBucket($"{stem}|{twin}|{requestKey}|{action}", DiagnosticsStride) == 0; /// - /// A FRESH compiler and driver for one (stem, twin). One caches its builtin and module - /// registries across analyses, and an analysis on a warm instance can answer differently from a cold one (measured - /// @ 43aab2658: Extract method's spelling of a builtin parameter type and SPY0220 on the IFormattable fixtures - /// moved with what the same instance analysed before, and with the thread interleaving at DOP 4 — ledgered as a - /// class finding). Isolating each (stem, twin) makes every verdict a function of that document alone: independent - /// of the parallelism, of the order stems run in, and of which stems a mode samples (so the regular pin pair is - /// the FULL pair restricted, as it claims). The handler's analyses and the diagnostics oracle share the - /// instance within the (stem, twin), in the fixed request order. Cost: ≈ 16 ms per (stem, twin) (measured). + /// A fresh driver for one (stem, twin) over the ONE the whole sweep shares — as the + /// language server shares one for the life of its process. Until #2250 (P54) each (stem, twin) needed its own + /// instance: an analysis on a warm instance could answer differently from a cold one (measured @ 43aab2658 — + /// a user `def len`/`def format` inside a `try:` rewrote the master builtin's parameters, so Extract method's + /// spelling of a builtin parameter type and SPY0220 on the IFormattable fixtures moved with what the instance + /// analysed before). P54 froze the master registries and gave every def its own symbol, so a verdict is a function + /// of its document alone on a shared instance too: independent of the parallelism, of the order stems run in and + /// of which stems a mode samples. The pins in code-action-shape-allowlist.txt are byte-identical (count and + /// digest) under the shared instance in regular and FULL mode — this sweep is the direction control for P54. + /// The handler's analyses and the diagnostics oracle share the instance in the fixed request order. /// private static (CompilerApi Api, LspCodeActionDriver Driver) Isolated() { - var api = new CompilerApi(CompilerNullLogger.Instance, s_references.Value); + var api = s_sharedApi.Value; return (api, new LspCodeActionDriver(api)); } private static readonly Lazy s_references = new(StdlibReferences.Paths); + private static readonly Lazy s_sharedApi = + new(() => new CompilerApi(CompilerNullLogger.Instance, s_references.Value)); + /// Documents a disposed per-(stem, twin) driver still held when its sweep ended (must stay 0). private static int s_retainedDocuments; diff --git a/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs b/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs new file mode 100644 index 000000000..b3da49c03 --- /dev/null +++ b/src/Sharpy.Lsp.Tests/Conformance/WarmInstanceParityTests.cs @@ -0,0 +1,643 @@ +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using Sharpy.Compiler; +using Sharpy.Lsp.Tests.E2E; +using Sharpy.TestInfrastructure.Integration; +using Sharpy.TestInfrastructure.Registry; +using Xunit; +using Xunit.Abstractions; +// The enclosing `Sharpy` namespace exposes Core's Pythonic collections, which shadow System's; SCG-qualify. +using SCG = System.Collections.Generic; +using CompilerNullLogger = Sharpy.Compiler.Logging.NullLogger; +using LspRange = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace Sharpy.Lsp.Tests.Conformance; + +/// +/// P54 (#2250) cells 2–3, found by the P47 code-action sweep (issuecomment-6120931669): an analysis, and every +/// semantic query a handler makes on it, is a function of the document alone — whatever the +/// analysed before (ORDER) or alongside (CONCURRENCY). One LSP server process holds one +/// for every document it serves, so a warm instance must answer as a cold process does. +/// +/// Matrix. The three P47 stems at the twin and selection the sweep measured them on () +/// × warmth {fresh — a new per target in this (warm) test process, the positive +/// control that separates instance state from process-wide state; warm-sequential — ONE shared instance after +/// the sweep's earlier documents (); warm-concurrent — one shared instance +/// serving the same documents at DOP , times} × operation {analyze — +/// the published diagnostics of the source; extract — Extract method through the REAL +/// SharpyCodeActionHandler on the selection, applied as a client must; applied-analyze — the published +/// diagnostics of the COLD applied text (the same text for every warmth, so the analysis is judged apart from the +/// extract)}. The oracle () is the P47 sweep's own: a child Sharpy.Lsp process per +/// document via (this project does not reference Sharpy.Cli). +/// +/// Fingerprint. Diagnostics: success (no error) + the sorted code@line:col (1-based). +/// Extract: the applied text's first parse error position (the P47 reading — (12,59) cold, (12,49) warm +/// @ 43aab2658) + a digest of the applied text. +/// +/// Seam measurement (Design Decision 4). Between every step of the sequential arm, and around the +/// concurrent arm, the masters' dictionary key sets are snapshotted () and the first +/// diverging key is printed to the test output and to .claude/tmp/warm-instance-parity/ (with the cold applied texts). +/// +/// KnownRed rows (, P54 Phase 1 convention): a deterministic row asserts the +/// divergence REPRODUCES (a cure turns it red as stale: drain the row); a probabilistic row reports +/// k/R and asserts nothing; every other cell asserts parity. No row is live since P54 Phase 2 task 3. SHARPY_WARM_PARITY_FULL=1 replays the P47 +/// FULL ordering (every stem's identity and crlf twin, then the cr twin in corpus order up to each target — about 20 +/// minutes) instead of the stride sample. +/// +public sealed class WarmInstanceParityTests : IClassFixture +{ + private readonly ITestOutputHelper _output; + private readonly ColdOracle _cold; + + public WarmInstanceParityTests(ColdOracle cold, ITestOutputHelper output) + { + _cold = cold; + _output = output; + } + + // ================================================================ + // The matrix + // ================================================================ + + /// One P47 cell: a corpus stem, the twin the sweep measured it on, and the Extract-method selection. + internal sealed record Target(string Stem, string Twin, LspRange Range) + { + public string Id => $"{Stem[(Stem.LastIndexOf('/') + 1)..]}#{Twin}"; + + public string Text => CodeActionShapeSweepTests.TwinOf(SweepCorpus.Corpus.Value.Corpus[Stem].Source, Twin); + } + + private static LspRange R(int l0, int c0, int l1, int c1) => new(new Position(l0, c0), new Position(l1, c1)); + + /// The three P47 stems at the cr twin and the selections of issuecomment-6120931669 (0-based LSP ranges). + internal static readonly Target[] Targets = + { + new("fstrings/format_iformattable_class_1988", CodeActionShapeSweepTests.Cr, R(12, 0, 19, 24)), + new("fstrings/format_no_dunder_format_refused_1988", CodeActionShapeSweepTests.Cr, R(8, 0, 16, 23)), + new("deferred_overload_annotated_pin_1589", CodeActionShapeSweepTests.Cr, R(4, 0, 9, 21)), + }; + + internal const string Fresh = "fresh"; + internal const string WarmSequential = "warm-sequential"; + internal const string WarmConcurrent = "warm-concurrent"; + internal static readonly string[] Warmths = { Fresh, WarmSequential, WarmConcurrent }; + + internal const string Analyze = "analyze"; + internal const string Extract = "extract"; + internal const string AppliedAnalyze = "applied-analyze"; + internal static readonly string[] Operations = { Analyze, Extract, AppliedAnalyze }; + + /// The concurrent arm's degree of parallelism (the P47 sweep's default) and repetitions. + internal const int Dop = 4; + + internal const int Repetitions = 4; + + /// Regular mode's prefix: the identity and crlf twins of the stems whose stable bucket under this stride is 0. + internal const int PrefixStride = 1024; + + /// + /// The earlier documents measured to flip the targets on ONE instance (): two + /// Wrap-in-try applied texts the P47 sweep analysed @ 43aab2658, found by replaying the sweep on one instance and + /// re-reading the targets after every document; the sweep no longer produces them, so they are replayed by text + /// right before each target (and shuffled into the concurrent arm). With them, the TYPE-definition twins + /// (: a class str and its struct/interface/union/delegate + /// and dataclass siblings inside a block, which wrote the master str until the type-definition seam). + /// + internal static readonly (string Name, string Text)[] TriggerTexts = WarmInstanceTriggers.AllTexts; + + /// The concurrent arm's marker for a trigger-text item. + private const string TriggerTwin = "trigger"; + + internal static string CellId(Target target, string warmth, string operation) => $"{target.Id} {warmth} {operation}"; + + internal static bool FullMode => System.Environment.GetEnvironmentVariable("SHARPY_WARM_PARITY_FULL") == "1"; + + /// One reading of the three operations on one target. + internal sealed record Reading(string Analyze, string Extract, string AppliedAnalyze) + { + public string Of(string operation) => operation switch + { + WarmInstanceParityTests.Analyze => Analyze, + WarmInstanceParityTests.Extract => Extract, + WarmInstanceParityTests.AppliedAnalyze => AppliedAnalyze, + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, "unknown operation"), + }; + } + + // ================================================================ + // KnownRed rows + // ================================================================ + + /// A KnownRed row: whether the cell is a probabilistic race (reported, never asserted) and its citation. + internal sealed record KnownRed(bool Probabilistic, string Row); + + /// + /// The cells measured red at the implementation sha, keyed by . Each row cites #2250 and its + /// measured reproduction k/R @ sha; "drains in Phase 2 task 3". + /// + internal static readonly SCG.IReadOnlyDictionary KnownRedCells = new SCG.Dictionary(StringComparer.Ordinal) + { + // Empty since P54 Phase 2 task 3. Cells 2 (ORDER, warm-sequential, deterministic) and 3 (CONCURRENCY, + // warm-concurrent, probabilistic) were one write: a trigger text's builtin-named def inside a `try:` body + // resolved by name to the master `len`/`format` and wrote its signature onto it, so the IFormattable sources + // gained SPY0220 and Extract method parsed at (12,55)/(8,55)/(5,148) instead of the cold (12,59)/(8,59)/(5,149). + // The def now declares its own symbol and the master's symbols are frozen. + }; + + [Fact] + public void KnownRedRows_NameCellsOfTheMatrix() + { + var cells = Targets.SelectMany(t => Warmths.SelectMany(w => Operations.Select(o => CellId(t, w, o)))).ToHashSet(StringComparer.Ordinal); + Assert.Equal(Targets.Length * Warmths.Length * Operations.Length, cells.Count); + foreach (var (cell, row) in KnownRedCells) + { + Assert.True(cells.Contains(cell), $"KnownRed row '{cell}' names no cell of the matrix"); + Assert.False(cell.Contains($" {Fresh} ", StringComparison.Ordinal), $"'{cell}': the fresh arm is the positive control; it carries no KnownRed row"); + Assert.Contains("#2250", row.Row, StringComparison.Ordinal); + Assert.Matches(@"\d+/\d+[^@]* @ [0-9a-f]{7,}", row.Row); + } + } + + // ================================================================ + // The arms + // ================================================================ + + /// + /// Positive control: a FRESH per target, in this test process, answers as the cold child + /// process on every operation — the oracle is not "always red", and a divergence in the warm arms is instance state. + /// Also pins that the oracle saw what the cells are about: Extract method is offered on every selection and its + /// applied text does not parse (the P47 shape), and the refused fixture's SPY0609s are published. + /// + [Fact] + public void Fresh_ANewInstancePerTarget_AnswersAsTheColdProcess() + { + var readings = new SCG.Dictionary>(StringComparer.Ordinal); + foreach (var target in Targets) + { + var api = NewApi(); + using var driver = new LspCodeActionDriver(api); + readings[target.Id] = new SCG.List { Measure(driver, target, _cold.Of(target).AppliedText) }; + } + + foreach (var target in Targets) + { + var cold = _cold.Of(target); + Assert.True(cold.AppliedText is not null, $"{target.Id}: the cold process offered no Extract method on {target.Range}"); + Assert.StartsWith("parse@", cold.Reading.Extract, StringComparison.Ordinal); + } + + Assert.Contains("SPY0609", _cold.Of(Targets[1]).Reading.Analyze, StringComparison.Ordinal); + Judge(Fresh, readings, new SCG.List()); + } + + /// + /// Cell 2 (ORDER): ONE shared and driver serve the sweep's earlier documents + /// (), then each target in the P47 order — its identity twin's offers, then the + /// analysis, the Extract-method request and the applied text's analysis of its cr twin — with the masters' key sets + /// snapshotted between every step. + /// + [Fact] + public void WarmSequential_OneSharedInstance_AnswersAsTheColdProcess() + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + var api = NewApi(); + using var driver = new LspCodeActionDriver(api); + api.PrimeAnalysisContextForTests(); + var log = new SCG.List { RunEnvironment() }; + var snapshot = MasterRegistryKeySets.Take(api); + var readings = new SCG.Dictionary>(StringComparer.Ordinal); + var served = 0; + foreach (var step in EarlierDocuments(FullMode)) + { + if (step.Target is { } target) + { + snapshot = Snapshot(api, snapshot, $"prefix ({served} documents served)", log); + foreach (var (name, text) in TriggerTexts) + { + api.Analyze(text, CancellationToken.None); + snapshot = Snapshot(api, snapshot, $"trigger text {name}", log); + } + + Serve(driver, api, target.Stem, CodeActionShapeSweepTests.Identity, applyAndAnalyze: false); + snapshot = Snapshot(api, snapshot, $"{target.Id} identity offers", log); + using var session = driver.Open(target.Text); + var analyze = DiagFingerprint(session.Diagnostics); + snapshot = Snapshot(api, snapshot, $"{target.Id} {Analyze}", log); + var extract = ExtractFingerprint(ExtractIn(session, target.Range)); + snapshot = Snapshot(api, snapshot, $"{target.Id} {Extract}", log); + var appliedAnalyze = AnalyzeIn(driver, _cold.Of(target).AppliedText); + snapshot = Snapshot(api, snapshot, $"{target.Id} {AppliedAnalyze}", log); + readings[target.Id] = new SCG.List { new(analyze, extract, appliedAnalyze) }; + served = 0; + } + else + { + Serve(driver, api, step.Stem, step.Twin, applyAndAnalyze: true); + served++; + } + } + + log.Add($"elapsed {clock.Elapsed.TotalSeconds:F1}s (full={FullMode})"); + Judge(WarmSequential, readings, log); + } + + /// + /// Cell 3 (CONCURRENCY): one shared and driver serve the regular prefix's documents and + /// the targets times in a seeded shuffle at DOP ; each target reading is + /// judged against the cold process (probabilistic: k/R). + /// + [Fact] + public void WarmConcurrent_OneSharedInstanceAtDop4_AnswersAsTheColdProcess() + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + var api = NewApi(); + using var driver = new LspCodeActionDriver(api); + api.PrimeAnalysisContextForTests(); + var log = new SCG.List { RunEnvironment() }; + var before = MasterRegistryKeySets.Take(api); + var documents = EarlierDocuments(full: false).Where(s => s.Target is null && s.Twin == CodeActionShapeSweepTests.Identity).ToArray(); + var random = new Random(2250); + var items = new SCG.List<(int Repetition, Target? Target, string Stem, string Twin)>(); + for (var repetition = 0; repetition < Repetitions; repetition++) + { + var round = documents.Select(d => (repetition, (Target?)null, d.Stem, d.Twin)) + .Concat(Targets.Select(t => (repetition, (Target?)t, t.Stem, t.Twin))) + .OrderBy(_ => random.Next()) + .ToArray(); + items.AddRange(round); + } + + var readings = new ConcurrentDictionary>(StringComparer.Ordinal); + foreach (var (name, _) in TriggerTexts) + items.Insert(random.Next(items.Count), (-1, null, name, TriggerTwin)); + Parallel.ForEach(items, new ParallelOptions { MaxDegreeOfParallelism = Dop }, item => + { + if (item.Twin == TriggerTwin) + api.Analyze(TriggerTexts.Single(t => t.Name == item.Stem).Text, CancellationToken.None); + else if (item.Target is { } target) + readings.GetOrAdd(target.Id, _ => new ConcurrentBag()).Add(Measure(driver, target, _cold.Of(target).AppliedText)); + else + Serve(driver, api, item.Stem, item.Twin, applyAndAnalyze: true); + }); + + var after = MasterRegistryKeySets.Take(api); + log.Add($"key sets across the concurrent arm ({items.Count} items, DOP {Dop}): first divergence {before.FirstDivergence(after)}"); + log.Add(before.Describe(after, 6)); + log.Add($"elapsed {clock.Elapsed.TotalSeconds:F1}s"); + Judge(WarmConcurrent, readings.ToDictionary(p => p.Key, p => p.Value.ToList(), StringComparer.Ordinal), log); + } + + /// + /// The verifier's F1 cell (P54 #2250 @ effba7776), as one Sharpy.Lsp served it: a later document that reads a member + /// the master str does not have () must publish what a + /// cold process publishes after EACH trigger text — opened through the server's document route and analysed by + /// text — was served on the same instance. Before the type-definition seam the class str text added a + /// _size backing field to the master str and the probe's SPY0203 vanished on the warm instance. + /// Positive controls: the cold oracle publishes SPY0203 for the probe (the cell reads what it is about), and each + /// probe opened first on the same instance — before any trigger — equals the cold reading. + /// + [Fact] + public async Task WarmSequential_LaterDocumentProbes_AnswerAsTheColdProcessAfterEveryTrigger() + { + var cold = new SCG.Dictionary(StringComparer.Ordinal); + foreach (var (name, text) in WarmInstanceTriggers.LaterDocumentProbes) + cold[name] = (await ColdAsync(text, null)).Diagnostics; + Assert.Contains("SPY0203", cold["str-has-no-_size"], StringComparison.Ordinal); + + var api = NewApi(); + using var driver = new LspCodeActionDriver(api); + var problems = new SCG.List(); + void ReadProbes(string after) + { + foreach (var (name, text) in WarmInstanceTriggers.LaterDocumentProbes) + { + using var session = driver.Open(text); + var warm = DiagFingerprint(session.Diagnostics); + _output.WriteLine($"{name} after {after}: {warm}"); + if (!string.Equals(warm, cold[name], StringComparison.Ordinal)) + problems.Add($"{name} after {after}: warm [{warm}] / cold [{cold[name]}]"); + } + } + + ReadProbes("nothing (the positive control)"); + Assert.True(problems.Count == 0, string.Join("\n", problems)); + foreach (var (name, text) in TriggerTexts) + { + using (driver.Open(text)) + { + } + + api.Analyze(text, CancellationToken.None); + ReadProbes($"trigger {name}"); + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + // ================================================================ + // Judging + // ================================================================ + + private void Judge(string warmth, SCG.IReadOnlyDictionary> readings, SCG.List log) + { + var problems = new SCG.List(); + foreach (var target in Targets) + { + var cold = _cold.Of(target); + log.Add($"cold {target.Id}: {cold.Reading} ({cold.Seconds:F1}s)"); + if (!readings.TryGetValue(target.Id, out var observed) || observed.Count == 0) + { + problems.Add($"{target.Id}: the {warmth} arm produced no reading"); + continue; + } + + foreach (var operation in Operations) + { + var cell = CellId(target, warmth, operation); + var expected = cold.Reading.Of(operation); + var differing = observed.Where(r => !string.Equals(r.Of(operation), expected, StringComparison.Ordinal)).ToArray(); + var line = $"{cell}: {differing.Length}/{observed.Count} differ from cold [{expected}]" + + (differing.Length > 0 ? $"; e.g. [{differing[0].Of(operation)}]" : ""); + log.Add(line); + if (KnownRedCells.TryGetValue(cell, out var row)) + { + if (!row.Probabilistic && differing.Length != observed.Count) + problems.Add($"{line} — the deterministic KnownRed row no longer reproduces ({row.Row}): stale, drain the row"); + } + else if (differing.Length > 0) + { + problems.Add($"{line} — a {warmth} instance must answer as the cold process (no KnownRed row)"); + } + } + } + + foreach (var entry in log) + _output.WriteLine(entry); + var reportDirectory = Path.Combine(FixtureRoots.RepositoryRoot, ".claude", "tmp", "warm-instance-parity"); + Directory.CreateDirectory(reportDirectory); + File.WriteAllLines(Path.Combine(reportDirectory, $"{warmth}.txt"), log); + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + /// Takes the masters' key sets and records the first divergence from under . + private static MasterRegistryKeySets Snapshot(CompilerApi api, MasterRegistryKeySets previous, string label, SCG.List log) + { + var now = MasterRegistryKeySets.Take(api); + log.Add($"keysets after {label}: {previous.FirstDivergence(now)}"); + return now; + } + + private static string RunEnvironment() + { + var runner = Path.Combine(AppContext.BaseDirectory, "xunit.runner.json"); + var threads = File.Exists(runner) + ? JsonNode.Parse(File.ReadAllText(runner))?["maxParallelThreads"]?.ToString() ?? "?" + : "?"; + return $"processors={System.Environment.ProcessorCount} xunit maxParallelThreads={threads} dop={Dop} repetitions={Repetitions}"; + } + + // ================================================================ + // The sweep's earlier documents + // ================================================================ + + /// One step of the P47 ordering: serve (stem, twin), or measure a target. + internal readonly record struct OrderStep(string Stem, string Twin, Target? Target); + + /// + /// The documents the P47 sweep's shared instance served before each target, in its order (identity twin of every + /// stem, crlf twin of every stem, then the cr twin in corpus order — a target is measured where the sweep reached + /// it). Regular mode keeps the stems whose stable bucket under is 0, plus the targets' + /// own stems; FULL mode keeps every stem. + /// + internal static SCG.IEnumerable EarlierDocuments(bool full) + { + var targets = Targets.ToDictionary(t => t.Stem, StringComparer.Ordinal); + var stems = SweepCorpus.Corpus.Value.Corpus.Keys + .Where(s => full || targets.ContainsKey(s) || SweepCorpus.StableBucket(s, PrefixStride) == 0) + .ToArray(); + foreach (var twin in new[] { CodeActionShapeSweepTests.Identity, CodeActionShapeSweepTests.Crlf }) + { + foreach (var stem in stems) + yield return new OrderStep(stem, twin, null); + } + + // The cr sweep reached the targets in corpus order; nothing after the last one is an earlier document. + var lastTarget = stems.Last(targets.ContainsKey); + foreach (var stem in stems) + { + yield return targets.TryGetValue(stem, out var target) + ? new OrderStep(stem, CodeActionShapeSweepTests.Cr, target) + : new OrderStep(stem, CodeActionShapeSweepTests.Cr, null); + if (stem == lastTarget) + yield break; + } + } + + // ================================================================ + // In-process operations (the sweep's per-document work) + // ================================================================ + + private static readonly Lazy s_references = new(StdlibReferences.Paths); + + /// A compiler with the server's default reference pair (Core + Stdlib), as Program.cs builds it. + private static CompilerApi NewApi() => new(CompilerNullLogger.Instance, s_references.Value); + + /// The three operations on through . + private static Reading Measure(LspCodeActionDriver driver, Target target, string? coldApplied) + { + using var session = driver.Open(target.Text); + var analyze = DiagFingerprint(session.Diagnostics); + var extract = ExtractFingerprint(ExtractIn(session, target.Range)); + return new Reading(analyze, extract, AnalyzeIn(driver, coldApplied)); + } + + private static string? ExtractIn(LspCodeActionDriver.Session session, LspRange range) + { + var action = session.Request(range).FirstOrDefault(a => a.Title.StartsWith("Extract method", StringComparison.Ordinal)); + return action is null ? null : session.Apply(action); + } + + private static string AnalyzeIn(LspCodeActionDriver driver, string? text) + { + if (text is null) + return "no-extract"; + using var session = driver.Open(text); + return DiagFingerprint(session.Diagnostics); + } + + /// + /// The P47 sweep's work on one (stem, twin) on the shared instance (RunCells): a non-identity twin first + /// requests the identity twin's offers; then every request the AST admits, and (when ) + /// every offered action applied and every applied text that parses analysed, with the source's own path-less + /// analysis (the diagnostics oracle). + /// + internal static void Serve(LspCodeActionDriver driver, CompilerApi api, string stem, string twin, bool applyAndAnalyze) + { + if (twin != CodeActionShapeSweepTests.Identity) + Serve(driver, api, stem, CodeActionShapeSweepTests.Identity, applyAndAnalyze: false); + var text = CodeActionShapeSweepTests.TwinOf(SweepCorpus.Corpus.Value.Corpus[stem].Source, twin); + var doc = CodeActionShapeSweepTests.SweepText.Of(text); + if (!doc.Parses || doc.UniformBreak is null) + return; + using var session = driver.Open(text); + var seen = new SCG.HashSet(StringComparer.Ordinal); + foreach (var request in CodeActionShapeSweepTests.BuildRequests(doc, session.Diagnostics)) + { + var context = request.Diagnostic is null ? Array.Empty() : new[] { request.Diagnostic }; + var actions = session.Request(request.Range, context); + if (!applyAndAnalyze) + continue; + foreach (var action in actions) + { + string applied; + try + { + applied = LspFormattingDriver.ApplyStrict(text, LspCodeActionDriver.EditsOf(action, session.Uri)); + } + catch (Exception ex) when (ex is InvalidOperationException or StrictEditApplicationException) + { + continue; + } + + if (seen.Add(applied) && SweepCorpus.Parses(applied)) + { + if (seen.Count == 1) + api.Analyze(text, CancellationToken.None); + api.Analyze(applied, CancellationToken.None); + } + } + } + } + + // ================================================================ + // Fingerprints + // ================================================================ + + /// success (no error-severity diagnostic) and the sorted code@line:col (1-based) of published diagnostics. + internal static string DiagFingerprint(SCG.IEnumerable diagnostics) + { + var list = diagnostics.ToArray(); + var rows = list.Select(d => $"{d.Code?.String ?? d.Code?.Long.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}@{d.Range.Start.Line + 1}:{d.Range.Start.Character + 1}") + .OrderBy(s => s, StringComparer.Ordinal); + return $"success={!list.Any(d => d.Severity == DiagnosticSeverity.Error)} [{string.Join(" ", rows)}]"; + } + + /// The applied text's first parse error (1-based line, column) — the P47 reading — and a digest of the text. + internal static string ExtractFingerprint(string? applied) + { + if (applied is null) + return "no-extract"; + var doc = CodeActionShapeSweepTests.SweepText.Of(applied); + var digest = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(applied)))[..12]; + return (doc.Parses ? "parses" : $"parse@({doc.FirstErrorLine},{doc.FirstErrorColumn})") + $" text={digest}"; + } + + // ================================================================ + // The cold oracle + // ================================================================ + + /// + /// The cold reading of every target: a child Sharpy.Lsp process opens the cr twin (its first + /// publishDiagnostics is analyze), answers the textDocument/codeAction request on the selection + /// (Extract method's edits, applied strictly, are extract); a SECOND child process opens that applied text + /// (applied-analyze) — a process that served one document only, each time. Targets run concurrently. + /// + public sealed class ColdOracle : IAsyncLifetime + { + private readonly ConcurrentDictionary _readings = new(StringComparer.Ordinal); + + /// A target's cold reading, the cold applied text, and the seconds the two processes took. + internal sealed record ColdReading(Reading Reading, string? AppliedText, double Seconds); + + internal ColdReading Of(Target target) => _readings.TryGetValue(target.Id, out var reading) + ? reading + : throw new InvalidOperationException($"no cold reading for {target.Id}"); + + public async Task InitializeAsync() + { + await Task.WhenAll(Targets.Select(async target => + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + var (analyze, applied) = await ColdAsync(target.Text, target.Range); + var appliedAnalyze = applied is null ? "no-extract" : (await ColdAsync(applied, null)).Diagnostics; + _readings[target.Id] = new ColdReading(new Reading(analyze, ExtractFingerprint(applied), appliedAnalyze), applied, clock.Elapsed.TotalSeconds); + if (applied is not null) + { + var directory = Path.Combine(FixtureRoots.RepositoryRoot, ".claude", "tmp", "warm-instance-parity"); + Directory.CreateDirectory(directory); + await File.WriteAllTextAsync(Path.Combine(directory, $"cold-applied-{target.Id.Replace('#', '.')}.spy"), applied); + } + })); + } + + public Task DisposeAsync() => Task.CompletedTask; + } + + private const string ColdUri = "file:///lsp-code-action-driver/doc1.spy"; + + /// One cold process: open , read its first publication, optionally apply Extract method on . + private static async Task<(string Diagnostics, string? Applied)> ColdAsync(string text, LspRange? range) + { + await using var client = LspTestClient.Start(); + await client.InitializeAsync(); + await client.DidOpenAsync(ColdUri, text); + JsonNode? publication = null; + while (publication is null) + { + var notification = await client.WaitForNotificationAsync("textDocument/publishDiagnostics"); + if (notification["uri"]?.GetValue() == ColdUri) + publication = notification; + } + + var diagnostics = ColdDiagFingerprint(publication["diagnostics"]!.AsArray()); + string? applied = null; + if (range is not null) + { + var result = await client.SendRequestAsync("textDocument/codeAction", new JsonObject + { + ["textDocument"] = new JsonObject { ["uri"] = ColdUri }, + ["range"] = RangeJson(range), + ["context"] = new JsonObject { ["diagnostics"] = new JsonArray() }, + }); + var action = result?.AsArray().FirstOrDefault(a => a?["title"]?.GetValue().StartsWith("Extract method", StringComparison.Ordinal) == true); + if (action is not null) + { + var edits = action["edit"]!["changes"]![ColdUri]!.AsArray().Select(e => new TextEdit + { + Range = new LspRange(PositionOf(e!["range"]!["start"]!), PositionOf(e["range"]!["end"]!)), + NewText = e["newText"]!.GetValue(), + }).ToArray(); + applied = LspFormattingDriver.ApplyStrict(text, edits); + } + } + + try + { + await client.ShutdownAsync(); + } + catch (Exception ex) when (ex is TimeoutException or InvalidOperationException or IOException) + { + // The reading is taken; a server that exits before answering shutdown is disposed (killed) below. + } + + return (diagnostics, applied); + } + + private static Position PositionOf(JsonNode node) => new(node["line"]!.GetValue(), node["character"]!.GetValue()); + + private static JsonObject RangeJson(LspRange range) => new() + { + ["start"] = new JsonObject { ["line"] = range.Start.Line, ["character"] = range.Start.Character }, + ["end"] = new JsonObject { ["line"] = range.End.Line, ["character"] = range.End.Character }, + }; + + /// over the wire form of a publication. + private static string ColdDiagFingerprint(JsonArray diagnostics) + { + var rows = diagnostics.Select(d => $"{d!["code"]?.ToString() ?? "-"}@{d["range"]!["start"]!["line"]!.GetValue() + 1}:{d["range"]!["start"]!["character"]!.GetValue() + 1}") + .OrderBy(s => s, StringComparer.Ordinal); + var success = !diagnostics.Any(d => d!["severity"]?.GetValue() == (int)DiagnosticSeverity.Error); + return $"success={success} [{string.Join(" ", rows)}]"; + } +} diff --git a/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs b/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs new file mode 100644 index 000000000..2803fc195 --- /dev/null +++ b/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalConcurrencyTests.cs @@ -0,0 +1,997 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Xunit; +using Xunit.Abstractions; +using SysBarrier = System.Threading.Barrier; +using SysRandom = System.Random; +using SysThread = System.Threading.Thread; + +namespace Sharpy.Stdlib.Tests.Conformance; + +/// +/// Every written stdlib module global () is +/// exercised through its PUBLIC module API under operation {write, read, write-then-read-on-another- +/// thread} × threads {1, 2, 8} × iterations, against CPython's model: a +/// module global is process-wide, and every call that reads or writes it is atomic (#2250, P54 +/// Design Decision 5). Each cell asserts no exception, every value in its domain, and — for the +/// cross-thread operation — that a write on thread A is what a read on thread B sees. +/// +/// The CPython facts each exerciser encodes were verified with python3 3.12.13 / numpy 2.5.1 +/// on 2026-10-14 (the literal results are in the comments). Threads are dedicated +/// s released together by a start barrier, never the xUnit thread, so a +/// [ThreadStatic] global cannot pass by being touched on the thread that checks it. +/// +/// KnownRed. A cell red at the base commit is a literal row citing +/// #2250 with its measured reproduction. A deterministic row asserts the defect still +/// reproduces (the Phase 3 cure turns it red as "stale — drain this row"); a probabilistic +/// row reports k/R and asserts nothing; every other cell asserts k == 0. The calendar, logging, +/// HTTPStatus and typed-load-cache rows are the GREEN positive controls (the oracle is not "always +/// red"), as are random's write and cross-thread cells, socket's and xml's cross-thread cells, and +/// numpy.random/cross-thread/1 (green at base: on one thread the then [ThreadStatic] generator was +/// the right one). Phase 3 of #2250 drained every row. +/// +/// Isolation. Each module's cells sit in the xUnit collection of the spy tests that +/// read the same global, so a concurrency cell never runs beside a seeded or set-then-read test of +/// the same module (Stdlib.Tests runs maxParallelThreads 4): random → PrngState (existing), +/// socket → SocketDefaultTimeout, calendar → CalendarFirstweekday, xml → +/// XmlRegisteredNamespaces, numpy.random → NumpyRandomState (definitions and spy +/// twins in Spy/SpyTestCollections.cs). Logging uses fresh logger names per cell and never +/// touches the root logger, HTTPStatus and the typed-load cache are only read, so those three need +/// no collection. Every cell restores the global it wrote. +/// +public class StdlibModuleGlobalConcurrencyTests +{ + internal const int Iterations = 2000; + + internal static readonly int[] ThreadCounts = { 1, 2, 8 }; + + internal const string OpWrite = "write"; + internal const string OpRead = "read"; + internal const string OpCrossThread = "cross-thread"; + + private static readonly TimeSpan JoinTimeout = TimeSpan.FromSeconds(60); + + /// A cell red at base: deterministic (asserted to reproduce) or probabilistic (reported only). + internal sealed record KnownRedRow(bool Deterministic, string Evidence); + + /// + /// The literal KnownRed rows, keyed "module/operation/threads"; each cites #2250. Measured over six runs of this class @ 486c9a88a (production code at base) on 2026-10-14, + /// Apple-silicon arm64, under the other lanes' load; "k/R" is the violating checks of one run. + /// + internal static readonly Dictionary KnownRed = new(StringComparer.Ordinal) + { + // random and socket: drained by Phase 3 task 1 — every read and write of RandomModuleModule._Random and + // SocketModuleModule._DefaultTimeout now happens under one module lock (random_module.spy / + // socket_module.spy). random/read/8 was red in 4/6 runs @ 486c9a88a and is 0/R after the cure (R in the + // commit body); the socket rows were never reproduced (0/6) — they drain because the cure lands: the + // 16-byte double? write now happens under the lock. + + // xml and numpy.random: drained by Phase 3 task 2 — Xml._registeredNamespaces is read and + // written under Xml.RegisteredNamespacesLock (xml/write/8 was red 6/6, xml/read/8 5/6 @ 486c9a88a; + // 0/R after the cure, R in the commit body), and NumpyRandom._rng is one process-wide generator + // under a lock instead of [ThreadStatic] (the eight deterministic rows reported stale, i.e. 0/R). + // Every cell now asserts k == 0; a new red lands here as a row citing its issue. + }; + + /// Every exerciser, keyed by the roster field it drives. + internal static readonly GlobalExerciser[] Exercisers = + { + new RandomExerciser(), new SocketExerciser(), new CalendarExerciser(), new XmlExerciser(), + new LoggingExerciser(), new NumpyRandomExerciser(), new HttpStatusExerciser(), new TypedLoadCacheExerciser(), + }; + + public static IEnumerable Matrix(string module) + { + var exerciser = Exercisers.Single(e => e.Module == module); + foreach (var operation in new[] { OpWrite, OpRead, OpCrossThread }) + { + if (exerciser.NotApplicable.ContainsKey(operation)) + continue; + foreach (var threads in ThreadCounts) + yield return new object[] { operation, threads }; + } + } + + /// Totality: every roster row has exactly one exerciser, and every exerciser drives a roster row. + [Fact] + public void EveryRosterRow_HasAnExerciser() + { + Assert.Equal( + StdlibModuleGlobalRosterTests.Roster.Select(r => r.Field).OrderBy(f => f, StringComparer.Ordinal), + Exercisers.Select(e => e.Field).OrderBy(f => f, StringComparer.Ordinal)); + Assert.Equal(Exercisers.Length, Exercisers.Select(e => e.Module).Distinct().Count()); + } + + /// Only the plan's six globals run every operation; a skipped operation names why it cannot race. + [Fact] + public void EverySkippedOperation_NamesItsReason() + { + foreach (var exerciser in Exercisers) + { + foreach (var (operation, reason) in exerciser.NotApplicable) + { + Assert.Contains(operation, new[] { OpWrite, OpRead, OpCrossThread }); + Assert.False(string.IsNullOrWhiteSpace(reason), $"{exerciser.Module}/{operation}"); + } + } + + Assert.Equal( + new[] { "calendar", "logging", "numpy.random", "random", "socket", "xml" }, + Exercisers.Where(e => e.NotApplicable.Count == 0).Select(e => e.Module).OrderBy(m => m, StringComparer.Ordinal)); + } + + /// Every KnownRed row names a real cell and cites #2250 with a measured reproduction. + [Fact] + public void EveryKnownRedRow_IsARealCellCitingTheIssue() + { + var cells = Exercisers + .SelectMany(e => Matrix(e.Module).Select(c => $"{e.Module}/{c[0]}/{c[1]}")) + .ToHashSet(StringComparer.Ordinal); + foreach (var (cell, row) in KnownRed) + { + Assert.Contains(cell, cells); + Assert.Contains("#2250", row.Evidence, StringComparison.Ordinal); + Assert.Contains(" @ ", row.Evidence, StringComparison.Ordinal); + } + } + + // ---- The cell classes, one per collection ---- + + [Collection("PrngState")] + public sealed class RandomCells : CellRunner + { + public RandomCells(ITestOutputHelper output) : base(output, "random") { } + + [Theory] + [MemberData(nameof(Matrix), "random", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + [Collection("SocketDefaultTimeout")] + public sealed class SocketCells : CellRunner + { + public SocketCells(ITestOutputHelper output) : base(output, "socket") { } + + [Theory] + [MemberData(nameof(Matrix), "socket", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + [Collection("CalendarFirstweekday")] + public sealed class CalendarCells : CellRunner + { + public CalendarCells(ITestOutputHelper output) : base(output, "calendar") { } + + [Theory] + [MemberData(nameof(Matrix), "calendar", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + [Collection("XmlRegisteredNamespaces")] + public sealed class XmlCells : CellRunner + { + public XmlCells(ITestOutputHelper output) : base(output, "xml") { } + + [Theory] + [MemberData(nameof(Matrix), "xml", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + public sealed class LoggingCells : CellRunner + { + public LoggingCells(ITestOutputHelper output) : base(output, "logging") { } + + [Theory] + [MemberData(nameof(Matrix), "logging", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + [Collection("NumpyRandomState")] + public sealed class NumpyRandomCells : CellRunner + { + public NumpyRandomCells(ITestOutputHelper output) : base(output, "numpy.random") { } + + [Theory] + [MemberData(nameof(Matrix), "numpy.random", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + public sealed class HttpStatusCells : CellRunner + { + public HttpStatusCells(ITestOutputHelper output) : base(output, "http.HTTPStatus") { } + + [Theory] + [MemberData(nameof(Matrix), "http.HTTPStatus", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + public sealed class TypedLoadCacheCells : CellRunner + { + public TypedLoadCacheCells(ITestOutputHelper output) : base(output, "json typed load") { } + + [Theory] + [MemberData(nameof(Matrix), "json typed load", MemberType = typeof(StdlibModuleGlobalConcurrencyTests))] + public void Cell(string operation, int threads) => Run(operation, threads); + } + + public abstract class CellRunner + { + private readonly ITestOutputHelper _output; + private readonly GlobalExerciser _exerciser; + + protected CellRunner(ITestOutputHelper output, string module) + { + _output = output; + _exerciser = Exercisers.Single(e => e.Module == module); + } + + protected void Run(string operation, int threads) + { + var cell = $"{_exerciser.Module}/{operation}/{threads}"; + var tally = new Tally(); + try + { + switch (operation) + { + case OpWrite: + _exerciser.Write(threads, tally); + break; + case OpRead: + _exerciser.Read(threads, tally); + break; + case OpCrossThread: + _exerciser.CrossThread(threads, tally); + break; + default: + throw new ArgumentOutOfRangeException(nameof(operation), operation, null); + } + } + finally + { + _exerciser.Restore(); + } + + Assert.True(tally.Checks > 0, $"{cell}: the cell made no checks"); + var report = $"{cell}: {tally.Violations}/{tally.Checks} violating checks ({tally.Exceptions} exception(s)); " + + $"field {_exerciser.Field}; samples: [{string.Join(" | ", tally.Samples)}]"; + _output.WriteLine(report); + + if (KnownRed.TryGetValue(cell, out var row)) + { + _output.WriteLine($"KnownRed ({(row.Deterministic ? "deterministic" : "probabilistic")}): {row.Evidence}"); + if (row.Deterministic) + { + Assert.True(tally.Violations > 0, + $"{cell} is a deterministic KnownRed row but no longer reproduces (0/{tally.Checks}) — stale: drain the row. {row.Evidence}"); + } + return; + } + + Assert.True(tally.Violations == 0, report); + } + } + + // ---- The harness ---- + + /// Counts checks, violating checks and exceptions across threads; keeps a few samples. + internal sealed class Tally + { + private int _checks; + private int _violations; + private int _exceptions; + private readonly ConcurrentQueue _samples = new(); + + public int Checks => Volatile.Read(ref _checks); + public int Violations => Volatile.Read(ref _violations); + public int Exceptions => Volatile.Read(ref _exceptions); + public IEnumerable Samples => _samples.Take(5); + + /// One check: false or throwing is a violation. + public void Check(Func ok, Func describe) + { + Interlocked.Increment(ref _checks); + try + { + if (!ok()) + { + Interlocked.Increment(ref _violations); + Sample(describe()); + } + } + catch (Exception e) + { + Interlocked.Increment(ref _exceptions); + Interlocked.Increment(ref _violations); + Sample($"{e.GetType().Name}: {e.Message}"); + } + } + + /// One operation that must not throw. + public void Do(Action action) => Check(() => { action(); return true; }, () => ""); + + /// A whole-run tally: of checks violated. + public void Count(int total, int bad, string describe) + { + Interlocked.Add(ref _checks, total); + if (bad > 0) + { + Interlocked.Add(ref _violations, bad); + Sample(describe); + } + } + + public void Exception(Exception e) + { + Interlocked.Increment(ref _checks); + Interlocked.Increment(ref _exceptions); + Interlocked.Increment(ref _violations); + Sample($"{e.GetType().Name}: {e.Message}"); + } + + private void Sample(string text) + { + if (_samples.Count < 5) + _samples.Enqueue(text); + } + } + + /// + /// Runs (id) on dedicated threads released + /// together by a start barrier; an escaped exception or a thread that does not finish is a violation. + /// + internal static void OnThreads(int threads, Tally tally, Action body) + { + using var start = new SysBarrier(threads); + var workers = Enumerable.Range(0, threads).Select(id => new SysThread(() => + { + try + { + start.SignalAndWait(); + body(id); + } + catch (Exception e) + { + tally.Exception(e); + } + }) + { IsBackground = true, Name = $"p54-global-{id}" }).ToArray(); + + foreach (var worker in workers) + worker.Start(); + foreach (var worker in workers) + { + if (!worker.Join(JoinTimeout)) + tally.Exception(new TimeoutException($"{worker.Name} did not finish in {JoinTimeout.TotalSeconds:0} s (a corrupted structure looping?)")); + } + } + + /// Evaluates on one fresh dedicated thread. + internal static T OnFreshThread(Func f) + { + T result = default!; + Exception? error = null; + var thread = new SysThread(() => + { + try + { + result = f(); + } + catch (Exception e) + { + error = e; + } + }) + { IsBackground = true, Name = "p54-global-fresh" }; + thread.Start(); + if (!thread.Join(JoinTimeout)) + throw new TimeoutException("the fresh thread did not finish"); + if (error != null) + throw new InvalidOperationException("the fresh thread threw", error); + return result; + } + + /// + /// The handoff schedule: in iteration i, thread (i mod T) writes, then thread ((i + 1) mod T) + /// reads and checks. A shared stage counter orders the steps (stage 2i: the write; 2i + 1: the + /// read) with a release/acquire pair — the happens-before edge any cross-thread call sequence + /// has; the other threads wait for their own turn. With T = 1 the reader is the writer; with + /// T ≥ 2 the read is always on another thread. (An 8-party barrier per step cost ~25 s per cell + /// on a loaded machine; two threads per step cost milliseconds.) + /// + internal static void Handoff(int threads, Tally tally, Action write, Action readAndCheck) + { + var stage = new int[1]; + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + if (i % threads == id) + { + AwaitStage(stage, 2 * i); + tally.Do(() => write(i)); + Volatile.Write(ref stage[0], 2 * i + 1); + } + + if ((i + 1) % threads == id) + { + AwaitStage(stage, 2 * i + 1); + readAndCheck(i); + Volatile.Write(ref stage[0], 2 * i + 2); + } + } + }); + } + + private static void AwaitStage(int[] stage, int value) + { + var spin = new SpinWait(); + var clock = System.Diagnostics.Stopwatch.StartNew(); + while (Volatile.Read(ref stage[0]) != value) + { + if (clock.Elapsed > JoinTimeout) + throw new TimeoutException($"stage {value} never came (at {Volatile.Read(ref stage[0])})"); + spin.SpinOnce(sleep1Threshold: -1); + } + } + + /// How many of are not matched one-for-one in . + internal static int MultisetMisses(IEnumerable expected, IEnumerable actual) + { + var remaining = new Dictionary(); + foreach (var value in expected) + remaining[value] = remaining.TryGetValue(value, out var n) ? n + 1 : 1; + var misses = 0; + foreach (var value in actual) + { + if (remaining.TryGetValue(value, out var n) && n > 0) + remaining[value] = n - 1; + else + misses++; + } + + return misses; + } + + // ---- The exercisers ---- + + internal abstract class GlobalExerciser + { + /// The roster key of the global this exerciser drives. + public abstract string Field { get; } + + /// The cell-id prefix (the Python module). + public abstract string Module { get; } + + /// Operations with no post-initialization write to race, each with the reason. + public virtual IReadOnlyDictionary NotApplicable { get; } = new Dictionary(); + + public virtual void Write(int threads, Tally tally) => throw new NotSupportedException(); + + public abstract void Read(int threads, Tally tally); + + public virtual void CrossThread(int threads, Tally tally) => throw new NotSupportedException(); + + public virtual void Restore() + { + } + } + + /// + /// random — CPython 3.12: random.__doc__ "...and is, therefore, threadsafe."; seed(7) on one thread + /// then random() on another == the re-seeded draw (True, 0.32383276483316237); 8 threads x 2000 + /// draws after seed(7) are, as a multiset, exactly the first 16000 draws of the seed-7 stream + /// (True), with 0 zeros; seed(i) on one thread then random() on another == the seed-i stream's + /// first value for every sampled i in [0, 2000) (True). Sharpy spells random() next_double(). + /// + private sealed class RandomExerciser : GlobalExerciser + { + public override string Field => "Sharpy.RandomModule.RandomModuleModule._Random"; + public override string Module => "random"; + + private static double[] Stream(int n, int seed = 7) + { + Sharpy.RandomModule.RandomModuleModule.Seed(seed); + return Enumerable.Range(0, n).Select(_ => Sharpy.RandomModule.RandomModuleModule.NextDouble()).ToArray(); + } + + public override void Write(int threads, Tally tally) + { + var first = Stream(1)[0]; + OnThreads(threads, tally, _ => + { + for (var i = 0; i < Iterations; i++) + tally.Do(() => Sharpy.RandomModule.RandomModuleModule.Seed(7)); + }); + var draw = OnFreshThread(Sharpy.RandomModule.RandomModuleModule.NextDouble); + tally.Check(() => draw == first, () => $"after every thread seeded 7 the next draw was {draw}, expected {first}"); + } + + public override void Read(int threads, Tally tally) + { + var expected = Stream(threads * Iterations); + Sharpy.RandomModule.RandomModuleModule.Seed(7); + var draws = new double[threads][]; + OnThreads(threads, tally, id => + { + draws[id] = new double[Iterations]; + for (var i = 0; i < Iterations; i++) + draws[id][i] = Sharpy.RandomModule.RandomModuleModule.NextDouble(); + }); + var all = draws.Where(d => d != null).SelectMany(d => d).ToArray(); + var outOfDomain = all.Count(d => !(d >= 0.0 && d < 1.0)); + var zeros = all.Count(d => d == 0.0); + var misses = MultisetMisses(expected, all); + tally.Count(all.Length, misses, + $"{misses} draw(s) are not the seed-7 stream's first {expected.Length} (lost or duplicated by a race); {zeros} zero(s), {outOfDomain} out of [0, 1)"); + } + + public override void CrossThread(int threads, Tally tally) + { + // A different seed per iteration, so a reader cannot pass on a generator it seeded itself. + var firsts = Enumerable.Range(0, Iterations).Select(seed => Stream(1, seed)[0]).ToArray(); + Handoff(threads, tally, + i => Sharpy.RandomModule.RandomModuleModule.Seed(i), + i => tally.Check(() => Sharpy.RandomModule.RandomModuleModule.NextDouble() == firsts[i], + () => $"iteration {i}: the draw after another thread's seed({i}) is not the seed-{i} stream's first value")); + } + + // The unseeded process state: no PrngState test reads before it seeds. + public override void Restore() => Sharpy.RandomModule.RandomModuleModule._Random = new SysRandom(); + } + + /// + /// socket — CPython 3.12: 8 threads x 2000 setdefaulttimeout(i % 5 + 1)/getdefaulttimeout() raise + /// nothing and every read is one of the written values; setdefaulttimeout(12.5) on a thread, then + /// getdefaulttimeout() on the main thread == 12.5. + /// + private sealed class SocketExerciser : GlobalExerciser + { + public override string Field => "Sharpy.SocketModule.SocketModuleModule._DefaultTimeout"; + public override string Module => "socket"; + + private static readonly double?[] Domain = { null, 1.0, 2.0, 3.0, 4.0, 5.0 }; + + private static bool InDomain(double? value) => Domain.Contains(value); + + public override void Write(int threads, Tally tally) + { + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var value = Domain[(id + i) % Domain.Length]; + tally.Do(() => Sharpy.SocketModule.SocketModuleModule.Setdefaulttimeout(value)); + } + }); + var final = OnFreshThread(Sharpy.SocketModule.SocketModuleModule.Getdefaulttimeout); + tally.Check(() => InDomain(final), () => $"after the writes getdefaulttimeout() is {final}"); + } + + public override void Read(int threads, Tally tally) + { + // Every thread alternates write and read, so reads race writes at T >= 2. + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + if ((id + i) % 2 == 0) + { + var value = Domain[(id + i) % Domain.Length]; + tally.Do(() => Sharpy.SocketModule.SocketModuleModule.Setdefaulttimeout(value)); + } + else + { + double? read = null; + tally.Check(() => InDomain(read = Sharpy.SocketModule.SocketModuleModule.Getdefaulttimeout()), + () => $"getdefaulttimeout() read {read?.ToString("R", System.Globalization.CultureInfo.InvariantCulture) ?? "None"}, not a written value (torn)"); + } + } + }); + } + + public override void CrossThread(int threads, Tally tally) + { + Handoff(threads, tally, + i => Sharpy.SocketModule.SocketModuleModule.Setdefaulttimeout(1.0 + (i % 5)), + i => tally.Check(() => Sharpy.SocketModule.SocketModuleModule.Getdefaulttimeout() == 1.0 + (i % 5), + () => $"iteration {i}: another thread's setdefaulttimeout({1.0 + (i % 5)}) is not seen")); + } + + public override void Restore() => Sharpy.SocketModule.SocketModuleModule.Setdefaulttimeout(null); + } + + /// + /// calendar — CPython 3.12: setfirstweekday(6) on a thread, then firstweekday() on the main thread + /// == 6 and monthcalendar(2024, 1)[0] == [0, 1, 2, 3, 4, 5, 6]; with 0, [1, 2, 3, 4, 5, 6, 7]. + /// Sharpy has no calendar.firstweekday() (findings ledger), so the read derives the first weekday + /// from monthcalendar(2024, 1): 1 January 2024 is a Monday, at index (0 − fw) mod 7. + /// + private sealed class CalendarExerciser : GlobalExerciser + { + public override string Field => "Sharpy.CalendarModule._moduleFirstweekday"; + public override string Module => "calendar"; + + private static int FirstWeekday() + { + var week = CalendarModule.Monthcalendar(2024, 1)[0]; + for (var j = 0; j < 7; j++) + { + if (week[j] == 1) + return (7 - j) % 7; + } + + return -1; + } + + public override void Write(int threads, Tally tally) + { + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var weekday = (id + i) % 7; + tally.Do(() => CalendarModule.Setfirstweekday(weekday)); + } + }); + var final = OnFreshThread(FirstWeekday); + tally.Check(() => final is >= 0 and <= 6, () => $"after the writes the first weekday is {final}"); + } + + public override void Read(int threads, Tally tally) + { + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + if ((id + i) % 2 == 0) + { + var weekday = (id + i) % 7; + tally.Do(() => CalendarModule.Setfirstweekday(weekday)); + } + else + { + var read = -1; + tally.Check(() => (read = FirstWeekday()) is >= 0 and <= 6, () => $"first weekday read {read}"); + } + } + }); + } + + public override void CrossThread(int threads, Tally tally) + { + Handoff(threads, tally, + i => CalendarModule.Setfirstweekday(i % 7), + i => tally.Check(() => FirstWeekday() == i % 7, + () => $"iteration {i}: another thread's setfirstweekday({i % 7}) is not seen")); + } + + public override void Restore() => CalendarModule.Setfirstweekday(0); + } + + /// + /// xml — CPython 3.12: 8 threads x 2000 distinct register_namespace(prefix, uri) calls all land + /// (every pair present in ElementTree._namespace_map afterwards: True). Nothing in Sharpy reads + /// Xml._registeredNamespaces @ 486c9a88a, so the reads read the field (InternalsVisibleTo). + /// + private sealed class XmlExerciser : GlobalExerciser + { + public override string Field => "Sharpy.Xml._registeredNamespaces"; + public override string Module => "xml"; + + private static bool Has(string prefix, string uri) + { + // The reader takes the registry's lock, as every production read and write does (#2250). + lock (Xml.RegisteredNamespacesLock) + { + return Xml._registeredNamespaces.TryGetValue(prefix, out var found) && found == uri; + } + } + + public override void Write(int threads, Tally tally) + { + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var (prefix, uri) = ($"p54w{id}x{i}", $"http://p54.example/{id}/{i}"); + tally.Do(() => Xml.RegisterNamespace(prefix, uri)); + } + }); + + var lost = 0; + try + { + for (var id = 0; id < threads; id++) + { + for (var i = 0; i < Iterations; i++) + { + if (!Has($"p54w{id}x{i}", $"http://p54.example/{id}/{i}")) + lost++; + } + } + } + catch (Exception e) + { + tally.Exception(e); + } + + tally.Count(1, lost > 0 ? 1 : 0, $"{lost} of {threads * Iterations} registered prefixes are missing or wrong afterwards"); + } + + public override void Read(int threads, Tally tally) + { + const int Seeded = 64; + for (var k = 0; k < Seeded; k++) + Xml.RegisterNamespace($"p54r{k}", $"http://p54.example/r/{k}"); + + // Every thread alternates registering a new prefix and reading a seeded one. + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + if ((id + i) % 2 == 0) + { + var (prefix, uri) = ($"p54n{id}x{i}", $"http://p54.example/n/{id}/{i}"); + tally.Do(() => Xml.RegisterNamespace(prefix, uri)); + } + else + { + var k = i % Seeded; + tally.Check(() => Has($"p54r{k}", $"http://p54.example/r/{k}"), + () => $"the seeded prefix p54r{k} was not found while another thread registered"); + } + } + }); + } + + public override void CrossThread(int threads, Tally tally) + { + Handoff(threads, tally, + i => Xml.RegisterNamespace("p54x", $"http://p54.example/x/{i}"), + i => tally.Check(() => Has("p54x", $"http://p54.example/x/{i}"), + () => $"iteration {i}: another thread's register_namespace is not seen")); + } + + // Clear() also resets a table a race corrupted, so the xml spy test that shares the + // collection starts from a sound dictionary. + public override void Restore() + { + lock (Xml.RegisteredNamespacesLock) + { + Xml._registeredNamespaces.Clear(); + } + } + } + + /// + /// logging — CPython 3.12: getLogger("p54.x") on a thread is the same object as getLogger("p54.x") + /// on the main thread (True). Names are fresh per cell and the root logger is never touched, so + /// no other test observes these loggers. + /// + private sealed class LoggingExerciser : GlobalExerciser + { + public override string Field => "Sharpy.Logging._loggers"; + public override string Module => "logging"; + + private static string FreshPrefix() => "p54." + Guid.NewGuid().ToString("N"); + + public override void Write(int threads, Tally tally) + { + // Every thread asks for the same 64 new names: each name must yield ONE logger. + var prefix = FreshPrefix(); + var seen = new Logger?[threads, 64]; + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var k = i % 64; + tally.Check(() => + { + var logger = Logging.GetLogger($"{prefix}.{k}"); + if (seen[id, k] == null) + seen[id, k] = logger; + return ReferenceEquals(seen[id, k], logger); + }, () => $"{prefix}.{k} returned two loggers on one thread"); + } + }); + + for (var k = 0; k < 64; k++) + { + var canonical = Logging.GetLogger($"{prefix}.{k}"); + var split = Enumerable.Range(0, threads).Count(id => !ReferenceEquals(seen[id, k], canonical)); + tally.Count(threads, split, $"{split} thread(s) hold a different logger for {prefix}.{k}"); + } + } + + public override void Read(int threads, Tally tally) + { + var prefix = FreshPrefix(); + var created = Enumerable.Range(0, 64).Select(k => Logging.GetLogger($"{prefix}.{k}")).ToArray(); + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var k = (id + i) % 64; + tally.Check(() => ReferenceEquals(Logging.GetLogger($"{prefix}.{k}"), created[k]), + () => $"{prefix}.{k} is not the logger created before the threads started"); + } + }); + } + + public override void CrossThread(int threads, Tally tally) + { + var prefix = FreshPrefix(); + var created = new Logger?[Iterations]; + Handoff(threads, tally, + i => created[i] = Logging.GetLogger($"{prefix}.{i}"), + i => tally.Check(() => ReferenceEquals(Logging.GetLogger($"{prefix}.{i}"), created[i]), + () => $"iteration {i}: getLogger on another thread is not the logger it created")); + } + } + + /// + /// numpy.random — numpy 2.5.1: np.random.seed(7) on a thread, then np.random.rand() on another == + /// the re-seeded draw (True, 0.07630828937395717); 8 threads x 2000 rand() after seed(7) are, as a + /// multiset, the first 16000 draws of the seed-7 stream (True); 8 threads x 2000 seed(7), then a + /// draw on another thread == the stream's first value (True); seed(i) on one thread then rand() on + /// another == the seed-i stream's first value for every sampled i (True). The global RandomState is + /// process-wide. + /// + private sealed class NumpyRandomExerciser : GlobalExerciser + { + public override string Field => "Sharpy.NumpyRandom._rng"; + public override string Module => "numpy.random"; + + private static double Draw() => NumpyRandom.Rand(1)[0]; + + /// The seed-7 stream as one thread sees it (the oracle numpy's process-wide state gives every thread). + private static double[] Stream(int n) => OnFreshThread(() => + { + NumpyRandom.Seed(7); + return Enumerable.Range(0, n).Select(_ => Draw()).ToArray(); + }); + + public override void Write(int threads, Tally tally) + { + var first = Stream(1)[0]; + OnThreads(threads, tally, _ => + { + for (var i = 0; i < Iterations; i++) + tally.Do(() => NumpyRandom.Seed(7)); + }); + var draw = OnFreshThread(Draw); + tally.Check(() => draw == first, () => $"after every thread seeded 7 a draw on another thread was {draw}, expected {first}"); + } + + public override void Read(int threads, Tally tally) + { + var expected = Stream(threads * Iterations); + OnFreshThread(() => + { + NumpyRandom.Seed(7); + return 0; + }); + var draws = new double[threads][]; + OnThreads(threads, tally, id => + { + draws[id] = new double[Iterations]; + for (var i = 0; i < Iterations; i++) + draws[id][i] = Draw(); + }); + var all = draws.Where(d => d != null).SelectMany(d => d).ToArray(); + var outOfDomain = all.Count(d => !(d >= 0.0 && d < 1.0)); + var misses = MultisetMisses(expected, all); + tally.Count(all.Length, misses, + $"{misses} draw(s) are not the seed-7 stream's first {expected.Length} (seed on another thread not seen); {outOfDomain} out of [0, 1)"); + } + + public override void CrossThread(int threads, Tally tally) + { + // A different seed per iteration, so a reader cannot pass on a generator it seeded itself + // (with one seed, a per-thread generator re-seeded by its own earlier write looks right). + var firsts = OnFreshThread(() => Enumerable.Range(0, Iterations).Select(seed => + { + NumpyRandom.Seed(seed); + return Draw(); + }).ToArray()); + Handoff(threads, tally, + i => NumpyRandom.Seed(i), + i => tally.Check(() => Draw() == firsts[i], + () => $"iteration {i}: the draw after another thread's seed({i}) is not the seed-{i} stream's first value")); + } + } + + /// + /// http.HTTPStatus — CPython 3.12: HTTPStatus is an IntEnum of 62 members; 8 threads x 2000 x 62 + /// HTTPStatus(value) lookups each return the member (0 mismatches). The value map is written only + /// by the type initializer, so only the read operation exists. + /// + private sealed class HttpStatusExerciser : GlobalExerciser + { + public override string Field => "Sharpy.HTTPStatus._valueMap"; + public override string Module => "http.HTTPStatus"; + + public override IReadOnlyDictionary NotApplicable { get; } = new Dictionary + { + [OpWrite] = "written only by HTTPStatus's private constructor, called only from its own static readonly initializers — inside the CLR type initializer, before any reader", + [OpCrossThread] = "no write after type initialization to hand off", + }; + + private static readonly HTTPStatus[] Members = typeof(HTTPStatus) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(HTTPStatus)) + .Select(f => (HTTPStatus)f.GetValue(null)!) + .ToArray(); + + public override void Read(int threads, Tally tally) + { + Assert.Equal(62, Members.Length); + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var member = Members[(id + i) % Members.Length]; + tally.Check(() => ReferenceEquals(HTTPStatus.FromValue(member.Value), member), + () => $"HTTPStatus.FromValue({member.Value}) is not {member.Name}"); + } + }); + } + } + + /// A typed-load target: one all-fields constructor, as a dataclass emits. + public sealed class P54Point + { + public int X { get; } + public int Y { get; } + + public P54Point(int x, int y) + { + X = x; + Y = y; + } + } + + /// + /// json.loads[T] — the typed doors' field-descriptor cache (no CPython counterpart). The cache is a + /// ConcurrentDictionary whose value is a function of its key, so only the read operation runs: + /// concurrent typed loads of one type all decode, and a missing field is refused by name. + /// + private sealed class TypedLoadCacheExerciser : GlobalExerciser + { + public override string Field => "Sharpy.TypedLoadContract.FieldCache"; + public override string Module => "json typed load"; + + public override IReadOnlyDictionary NotApplicable { get; } = new Dictionary + { + [OpWrite] = "GetOrAdd keyed by a Type with a factory that is a function of that Type: a concurrent first touch publishes one of several equal values", + [OpCrossThread] = "the cached value is a function of its key; there is no written state to hand off", + }; + + public override void Read(int threads, Tally tally) + { + OnThreads(threads, tally, id => + { + for (var i = 0; i < Iterations; i++) + { + var (x, y) = (id, i); + if (i % 2 == 0) + { + tally.Check(() => + { + var result = Sharpy.Json.Loads($"{{\"x\": {x}, \"y\": {y}}}"); + return result.IsOk && result.Unwrap().X == x && result.Unwrap().Y == y; + }, () => $"json.loads[P54Point] of ({x}, {y}) did not decode"); + } + else + { + tally.Check(() => + { + var result = Sharpy.Json.Loads($"{{\"x\": {x}}}"); + return result.IsErr && result.UnwrapErr().Msg.Contains("y", StringComparison.Ordinal); + }, () => "json.loads[P54Point] without y was not refused naming y"); + } + } + }); + } + } +} diff --git a/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalRosterTests.cs b/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalRosterTests.cs new file mode 100644 index 000000000..b15734d99 --- /dev/null +++ b/src/Sharpy.Stdlib.Tests/Conformance/StdlibModuleGlobalRosterTests.cs @@ -0,0 +1,509 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Sharpy.TestInfrastructure.Integration; +using Xunit; +using Xunit.Abstractions; + +namespace Sharpy.Stdlib.Tests.Conformance; + +/// +/// Every stdlib module global that is WRITTEN after its initializer is a literal roster row naming +/// its CPython model and its synchronization (#2250, P54 Design Decision 5). +/// +/// CPython's module globals are process-wide and every call that reads or writes one is safe +/// from several threads (the GIL makes each C-level call atomic). In Sharpy a module global is a +/// static field, so one written after its initializer is shared state that several threads +/// can race on. The roster is decided by a census, not by memory: a Roslyn scan over +/// src/Sharpy.Stdlib/**/*.cs (the generated spy C# included; bin, obj and the +/// two directories the project file removes from compilation, spy/ and modules/, +/// excluded), bound with a semantic model built from the Stdlib's own references. +/// +/// The predicate (syntactic, bound). A static, non-const field declared in the scanned +/// sources is written when it is the target of a simple, compound or ??= assignment +/// (tuple-deconstruction targets included), of ++/--, of an element assignment +/// (F[k] = v), or is passed by ref/out to a callee that may write it — anywhere +/// outside the field's own initializer — or is the receiver of an invocation whose member name is +/// in the literal (F.Add(...), F?.Clear(), +/// F[k].Add(...)). The target must BIND to the field symbol, so a local or an instance field +/// that shares the name is not a write. The ~60 spy "constants" (math.pi, +/// socket.AF_INET, …) are emitted as public static non-readonly fields and are +/// excluded by this predicate — never written after their initializer — not by a list +/// (). +/// +/// A written static without a row is red (a new process-wide global needs its CPython model +/// and synchronization named); a row the census no longer finds is red (stale — drain it). The +/// totality is anchored to the literal , counted at landing +/// @ 486c9a88a (the plan's six plus two the census found), not derived from the scan. Each row is exercised at 1/2/8 threads by +/// . +/// +public class StdlibModuleGlobalRosterTests +{ + /// One written module global: its field, declaring file, CPython model, current synchronization, and the issue whose Phase 3 cures it (null: already safe). + internal sealed record GlobalRow(string Field, string File, string CPythonModel, string Synchronization, string? CuredBy); + + private const string ProcessWideUnderTheGil = + "process-wide module global, thread-safe under the GIL"; + + /// The literal roster. Each row's field key is "<containing type>.<field>". + internal static readonly GlobalRow[] Roster = + { + // random_module.spy:5 → generated Random/Random.cs:18; seed() swaps the instance. + // CPython 3.12 (verified): random.__doc__ "...and is, therefore, threadsafe." + new("Sharpy.RandomModule.RandomModuleModule._Random", "src/Sharpy.Stdlib/Random/Random.cs", + ProcessWideUnderTheGil + " (random.__doc__: \"threadsafe\")", + "one module lock (random_module.spy `_lock`, a threading.Lock) around every read and write", "#2250"), + // socket_module.spy → generated Socket/SocketModule.cs:38; setdefaulttimeout() writes it. + new("Sharpy.SocketModule.SocketModuleModule._DefaultTimeout", "src/Sharpy.Stdlib/Socket/SocketModule.cs", + ProcessWideUnderTheGil + " (one pointer write)", + "one module lock (socket_module.spy `_lock`, a threading.Lock) around every read and write", "#2250"), + // Calendar/CalendarConstants.cs:37; setfirstweekday() writes it. An int write is atomic. + new("Sharpy.CalendarModule._moduleFirstweekday", "src/Sharpy.Stdlib/Calendar/CalendarConstants.cs", + ProcessWideUnderTheGil, + "none — an int, atomic by the CLR memory model", null), + // Xml/XmlFunctions.cs:185; register_namespace() writes it; nothing in production reads it @ 486c9a88a. + new("Sharpy.Xml._registeredNamespaces", "src/Sharpy.Stdlib/Xml/XmlFunctions.cs", + ProcessWideUnderTheGil + " (ElementTree._namespace_map, a dict)", + "lock (Xml.RegisteredNamespacesLock) around every read and write", "#2250"), + // Logging/LoggingModule.cs:27; getLogger() adds to it. + new("Sharpy.Logging._loggers", "src/Sharpy.Stdlib/Logging/LoggingModule.cs", + ProcessWideUnderTheGil + " (Manager.loggerDict)", + "ConcurrentDictionary", null), + // Numpy/NumpyRandom.cs:22; seed() writes it. + new("Sharpy.NumpyRandom._rng", "src/Sharpy.Stdlib/Numpy/NumpyRandom.cs", + "process-wide global RandomState (numpy 2.5.1, verified: a seed on one thread is seen by the next draw on another)", + "one process-wide System.Random; lock (NumpyRandom.RngLock) around every read and write", "#2250"), + + // ---- Found by this census beyond the plan's six (findings-ledger rows, P54 Phase 1) ---- + + // Http/HTTPStatus.cs:20; written by the PRIVATE instance constructor (:31), which only the + // type's own 62 `public static readonly HTTPStatus X = new HTTPStatus(...)` initializers call, + // so every write runs inside the CLR type initializer — complete before any reader. + // CPython 3.12 (verified): HTTPStatus is an IntEnum, len(_value2member_map_) == 62. + new("Sharpy.HTTPStatus._valueMap", "src/Sharpy.Stdlib/Http/HTTPStatus.cs", + "process-wide class attribute built once at class creation (IntEnum _value2member_map_)", + "the CLR type initializer — written only during HTTPStatus's static initialization", null), + // TypedLoadContract.cs:36; FieldDescriptorsOf (:226) GetOrAdds per target Type (json/yaml typed doors). + new("Sharpy.TypedLoadContract.FieldCache", "src/Sharpy.Stdlib/TypedLoadContract.cs", + "no CPython counterpart — an implementation cache whose value is a function of its key (the Type)", + "ConcurrentDictionary", null), + }; + + /// + /// Written globals @ 486c9a88a, counted at landing — the totality anchor: the plan's six + /// (P54 cells 6–11) plus the two this census found (HTTPStatus._valueMap, TypedLoadContract.FieldCache). + /// + private const int ExpectedWrittenGlobals = 8; + + /// + /// Invoked member names that mutate their receiver — literal: the .NET collection mutators and + /// the Sharpy collection mutators (Python names in their C# spelling). + /// + internal static readonly HashSet MutatorRoster = new(StringComparer.Ordinal) + { + "Add", "AddRange", "Remove", "RemoveAt", "RemoveAll", "RemoveRange", "Clear", + "TryAdd", "TryRemove", "TryUpdate", "AddOrUpdate", "GetOrAdd", + "Enqueue", "Dequeue", "TryDequeue", "Push", "Pop", "TryPop", + "Insert", "InsertRange", "Set", "SetValue", "SetAll", "Sort", "Reverse", + "UnionWith", "ExceptWith", "IntersectWith", "SymmetricExceptWith", + "Append", "Extend", "Update", "Setdefault", "Discard", "Popitem", + }; + + private readonly ITestOutputHelper _output; + + public StdlibModuleGlobalRosterTests(ITestOutputHelper output) => _output = output; + + private static readonly Lazy StdlibCensus = new(() => Census.Run(StdlibCompilation())); + + [Fact] + public void TheSemanticModelBinds_TheStdlibCompilationHasNoErrors() + { + var census = StdlibCensus.Value; + + // A degraded model binds nothing and the census passes vacuously — so the compilation must + // be the Stdlib's own, error-free (references + preprocessor symbols of the net10.0 build). + Assert.True(census.Files > 200, $"the scan must read the Stdlib sources; read {census.Files} file(s)"); + Assert.True(census.Errors.Count == 0, + $"{census.Errors.Count} compilation error(s) — the semantic model is degraded:\n " + + string.Join("\n ", census.Errors.Take(20))); + Assert.True(census.UnboundTargets.Count == 0, + "assignment targets / mutator receivers that bind to no symbol:\n " + string.Join("\n ", census.UnboundTargets.Take(20))); + } + + [Fact] + public void EveryWrittenStatic_IsARosterRow_AndEveryRowIsWritten() + { + var census = StdlibCensus.Value; + foreach (var (field, writes) in census.Written.OrderBy(w => w.Key, StringComparer.Ordinal)) + _output.WriteLine($"{field} ({census.Declared[field]}): " + string.Join("; ", writes)); + + var rostered = Roster.Select(r => r.Field).ToHashSet(StringComparer.Ordinal); + var unlisted = census.Written.Keys.Where(f => !rostered.Contains(f)).OrderBy(f => f, StringComparer.Ordinal) + .Select(f => $" unlisted {f} declared {census.Declared[f]}, written at {string.Join("; ", census.Written[f])}"); + var stale = Roster.Where(r => !census.Written.ContainsKey(r.Field)) + .Select(r => $" stale row {r.Field} — the census no longer finds a write (drain the row)"); + var problems = unlisted.Concat(stale).ToList(); + + Assert.True(problems.Count == 0, + "the stdlib's written module globals differ from the roster:\n" + string.Join("\n", problems) + + "\nA written static is a process-wide global: add a row naming its CPython model and its " + + "synchronization (and a StdlibModuleGlobalConcurrencyTests exerciser), or make it init-once."); + } + + [Fact] + public void Totality_IsTheLiteralCount() + { + Assert.Equal(ExpectedWrittenGlobals, Roster.Length); + Assert.Equal(ExpectedWrittenGlobals, StdlibCensus.Value.Written.Count); + } + + [Fact] + public void EveryRow_NamesItsModelSynchronizationAndDeclaringFile() + { + var census = StdlibCensus.Value; + foreach (var row in Roster) + { + Assert.False(string.IsNullOrWhiteSpace(row.CPythonModel), row.Field); + Assert.False(string.IsNullOrWhiteSpace(row.Synchronization), row.Field); + Assert.True(census.Declared.TryGetValue(row.Field, out var declared), $"{row.Field} is not declared in the scanned sources"); + Assert.StartsWith(row.File + ":", declared, StringComparison.Ordinal); + } + + // The rows Phase 3 of #2250 cures — written out, so a row cannot silently drop its citation. + Assert.Equal( + new[] { "Sharpy.NumpyRandom._rng", "Sharpy.RandomModule.RandomModuleModule._Random", "Sharpy.SocketModule.SocketModuleModule._DefaultTimeout", "Sharpy.Xml._registeredNamespaces" }, + Roster.Where(r => r.CuredBy == "#2250").Select(r => r.Field).OrderBy(f => f, StringComparer.Ordinal)); + } + + /// Positive control: the generated random module's seed() assigns _Random = new …. + [Fact] + public void PositiveControl_GeneratedRandomSeedIsAWrite() + { + var census = StdlibCensus.Value; + Assert.True(census.Written.TryGetValue("Sharpy.RandomModule.RandomModuleModule._Random", out var writes), + "the census must find random.seed()'s write to _Random"); + Assert.Contains(writes, w => w.File == "src/Sharpy.Stdlib/Random/Random.cs" && w.Member == "Seed" && w.Kind == "SimpleAssignmentExpression"); + } + + /// + /// Negative control: a spy constant is a static NON-readonly field (as every spy module-level + /// binding is), it is scanned, and it is not written — excluded by the predicate, not a list. + /// + [Fact] + public void NegativeControl_ASpyConstantIsScannedAndNotWritten() + { + var census = StdlibCensus.Value; + foreach (var constant in new[] { "Sharpy.MathModule.MathModuleModule.Pi", "Sharpy.SocketModule.SocketModuleModule.AF_INET" }) + { + Assert.True(census.Declared.ContainsKey(constant), $"{constant} must be scanned as a static field"); + Assert.True(census.NonReadonly.Contains(constant), $"{constant} must be a non-readonly static (the spy emission) for this control to mean anything"); + Assert.False(census.Written.ContainsKey(constant), $"{constant} is init-once and must not be flagged"); + } + } + + public static IEnumerable WriteSnippets() => new[] + { + new object[] { "simple", "F = 1;" }, + new object[] { "qualified", "C.F = 1;" }, + new object[] { "compound", "F += 1;" }, + new object[] { "coalesce", "S ??= \"x\";" }, + new object[] { "increment", "F++;" }, + new object[] { "prefix-decrement", "--F;" }, + new object[] { "deconstruction", "(F, local) = (1, 2);" }, + new object[] { "element", "D[\"k\"] = 1;" }, + new object[] { "ref-argument", "System.Threading.Interlocked.Increment(ref F);" }, + new object[] { "out-argument", "D.TryGetValue(\"k\", out F);" }, + new object[] { "mutator", "D.Add(\"k\", 1);" }, + new object[] { "conditional-mutator", "L?.Clear();" }, + new object[] { "nested-mutator", "N[\"k\"].Add(1);" }, + }; + + /// Every arm of the predicate is a write — an arm whose matcher broke would otherwise pass as "no writes". + [Theory] + [MemberData(nameof(WriteSnippets))] + public void Synthetic_EveryPredicateArmIsAWrite(string arm, string statement) + { + var census = Census.Run(SyntheticCompilation($"void M() {{ int local = 0; {statement} }}")); + + Assert.True(census.Errors.Count == 0, arm + ": " + string.Join("; ", census.Errors)); + Assert.True(census.Written.Count == 1, $"{arm}: expected one written static, found [{string.Join(", ", census.Written.Keys)}]"); + } + + [Fact] + public void Synthetic_InitializersReadsLocalsAndInstanceFieldsAreNotWrites() + { + var census = Census.Run(SyntheticCompilation(""" + int I; + static readonly System.Collections.Generic.Dictionary Init = new() { ["a"] = 1 }; + void M(C other) + { + int F = 0; + F = 2; + I = 3; + other.I = 4; + var d = D["k"]; + var n = D.Count; + D.ContainsKey("k"); + System.Threading.Volatile.Read(ref S); + L?.Contains(1); + var copy = D.ToString(); + } + """)); + + Assert.True(census.Errors.Count == 0, string.Join("; ", census.Errors)); + Assert.True(census.Declared.Count >= 5, "control: the synthetic statics are scanned"); + Assert.True(census.Written.Count == 0, "not writes: " + string.Join(", ", census.Written.Select(w => $"{w.Key} at {string.Join("; ", w.Value)}"))); + } + + private static CSharpCompilation SyntheticCompilation(string members) + { + var source = $$""" + namespace N + { + public class C + { + public static int F; + public static string? S; + public static System.Collections.Generic.Dictionary D = new(); + public static System.Collections.Generic.List? L = new(); + public static readonly System.Collections.Generic.Dictionary> N = new(); + {{members}} + } + } + """; + return CSharpCompilation.Create( + "Synthetic", + new[] { CSharpSyntaxTree.ParseText(source, ParseOptions, path: "src/Sharpy.Stdlib/Synthetic.cs") }, + FrameworkReferences(), + CompilationOptions); + } + + // ---- The Stdlib compilation ---- + + private static readonly CSharpParseOptions ParseOptions = new( + LanguageVersion.Preview, + preprocessorSymbols: new[] { "NET", "NET10_0", "NET10_0_OR_GREATER", "NETCOREAPP", "RELEASE" }); + + private static readonly CSharpCompilationOptions CompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + allowUnsafe: true); + + private static CSharpCompilation StdlibCompilation() + { + var root = SysPath.Combine(FixtureRoots.RepositoryRoot, "src", "Sharpy.Stdlib"); + var excluded = new[] { "bin", "obj", "spy", "modules" } + .Select(d => SysPath.Combine(root, d) + SysPath.DirectorySeparatorChar) + .ToArray(); + var trees = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(f => !excluded.Any(x => f.StartsWith(x, StringComparison.Ordinal))) + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => CSharpSyntaxTree.ParseText( + File.ReadAllText(f), ParseOptions, + path: SysPath.GetRelativePath(FixtureRoots.RepositoryRoot, f).Replace('\\', '/'))) + .ToList(); + + // The Stdlib's own direct references (Sharpy.Core, MathNet.Numerics, Microsoft.Data.Sqlite, + // Tomlyn, YamlDotNet, …) as the test host loaded them, plus the shared framework. + var references = FrameworkReferences().ToList(); + var frameworkDir = RuntimeEnvironment.GetRuntimeDirectory(); + foreach (var name in typeof(Sharpy.NumpyRandom).Assembly.GetReferencedAssemblies()) + { + var location = Assembly.Load(name).Location; + if (!location.StartsWith(frameworkDir, StringComparison.Ordinal)) + references.Add(MetadataReference.CreateFromFile(location)); + } + + return CSharpCompilation.Create("Sharpy.Stdlib", trees, references, CompilationOptions); + } + + private static IEnumerable FrameworkReferences() + => Directory.EnumerateFiles(RuntimeEnvironment.GetRuntimeDirectory(), "*.dll") + .Where(IsManagedAssembly) + .Select(f => MetadataReference.CreateFromFile(f)); + + private static bool IsManagedAssembly(string path) + { + try + { + AssemblyName.GetAssemblyName(path); + return true; + } + catch (BadImageFormatException) + { + return false; + } + } + + // ---- The census ---- + + internal sealed record WriteSite(string File, int Line, string Member, string Kind) + { + public override string ToString() => $"{File}:{Line} {Member} ({Kind})"; + } + + internal sealed class Census + { + public int Files { get; private init; } + public System.Collections.Generic.List Errors { get; } = new(); + public System.Collections.Generic.List UnboundTargets { get; } = new(); + + /// Every static non-const field declared in the scanned sources → "file:line". + public Dictionary Declared { get; } = new(StringComparer.Ordinal); + + /// The declared statics that are not readonly. + public HashSet NonReadonly { get; } = new(StringComparer.Ordinal); + + /// The written statics → their write sites. + public Dictionary> Written { get; } = new(StringComparer.Ordinal); + + public static Census Run(CSharpCompilation compilation) + { + var census = new Census { Files = compilation.SyntaxTrees.Count() }; + census.Errors.AddRange(compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString())); + + foreach (var tree in compilation.SyntaxTrees) + { + var model = compilation.GetSemanticModel(tree); + var root = tree.GetRoot(); + + foreach (var declarator in root.DescendantNodes().OfType() + .Where(v => v.Parent?.Parent is FieldDeclarationSyntax)) + { + if (model.GetDeclaredSymbol(declarator) is IFieldSymbol { IsStatic: true, IsConst: false } field) + { + census.Declared[Key(field)] = $"{tree.FilePath}:{Line(declarator)}"; + if (!field.IsReadOnly) + census.NonReadonly.Add(Key(field)); + } + } + + foreach (var node in root.DescendantNodes()) + { + switch (node) + { + case AssignmentExpressionSyntax assignment: + foreach (var target in Targets(assignment.Left)) + census.Record(model, target, assignment, node.Kind().ToString()); + break; + case PrefixUnaryExpressionSyntax prefix + when prefix.IsKind(SyntaxKind.PreIncrementExpression) || prefix.IsKind(SyntaxKind.PreDecrementExpression): + census.Record(model, prefix.Operand, prefix, node.Kind().ToString()); + break; + case PostfixUnaryExpressionSyntax postfix + when postfix.IsKind(SyntaxKind.PostIncrementExpression) || postfix.IsKind(SyntaxKind.PostDecrementExpression): + census.Record(model, postfix.Operand, postfix, node.Kind().ToString()); + break; + case ArgumentSyntax argument + when argument.RefKindKeyword.IsKind(SyntaxKind.RefKeyword) || argument.RefKindKeyword.IsKind(SyntaxKind.OutKeyword): + // Volatile.Read(ref F) takes a ref and only reads: a literal read-only callee list. + if (!IsReadOnlyRefCallee(model, argument)) + census.Record(model, argument.Expression, argument, argument.RefKindKeyword.Text + "-argument"); + break; + case InvocationExpressionSyntax invocation: + var receiver = MutatorReceiver(invocation); + if (receiver != null) + census.Record(model, receiver, invocation, "mutator ." + MemberName(invocation)); + break; + } + } + } + + return census; + } + + private static readonly HashSet ReadOnlyRefCallees = new(StringComparer.Ordinal) + { + "System.Threading.Volatile.Read", "System.Threading.Interlocked.Read", + }; + + private static bool IsReadOnlyRefCallee(SemanticModel model, ArgumentSyntax argument) + => argument.Parent?.Parent is InvocationExpressionSyntax invocation + && model.GetSymbolInfo(invocation).Symbol is IMethodSymbol method + && ReadOnlyRefCallees.Contains(method.ContainingType.ToDisplayString() + "." + method.Name); + + private static IEnumerable Targets(ExpressionSyntax left) => left switch + { + TupleExpressionSyntax tuple => tuple.Arguments.SelectMany(a => Targets(a.Expression)), + DeclarationExpressionSyntax => Array.Empty(), + _ => new[] { left }, + }; + + private static string MemberName(InvocationExpressionSyntax invocation) => invocation.Expression switch + { + MemberAccessExpressionSyntax access => access.Name.Identifier.Text, + MemberBindingExpressionSyntax binding => binding.Name.Identifier.Text, + _ => "", + }; + + /// The receiver of R.Mutator(...) or R?.Mutator(...) for a rostered mutator name. + private static ExpressionSyntax? MutatorReceiver(InvocationExpressionSyntax invocation) + { + if (!MutatorRoster.Contains(MemberName(invocation))) + return null; + return invocation.Expression switch + { + MemberAccessExpressionSyntax access => access.Expression, + MemberBindingExpressionSyntax => invocation.Ancestors().OfType().FirstOrDefault()?.Expression, + _ => null, + }; + } + + private void Record(SemanticModel model, ExpressionSyntax target, SyntaxNode site, string kind) + { + var expression = target; + if (expression is ElementAccessExpressionSyntax element) + { + expression = element.Expression; + kind = "element " + kind; + } + + // Only a name or a member access can bind to a field; a call result, a cast or an + // object-initializer slot ([k] = v inside new() { ... }) cannot. + if (expression is not (IdentifierNameSyntax or MemberAccessExpressionSyntax)) + return; + + var symbol = model.GetSymbolInfo(expression).Symbol; + if (symbol == null) + { + if (expression is IdentifierNameSyntax { Identifier.Text: "_" }) + return; // discard + UnboundTargets.Add($"{site.SyntaxTree.FilePath}:{Line(site)} {expression}"); + return; + } + + if (symbol is not IFieldSymbol { IsStatic: true, IsConst: false } field + || !field.Locations.Any(l => l.IsInSource)) + return; + + // Outside the field's own initializer. + var ownDeclarator = site.Ancestors().OfType() + .FirstOrDefault(v => v.Parent?.Parent is FieldDeclarationSyntax); + if (ownDeclarator != null && SymbolEqualityComparer.Default.Equals(model.GetDeclaredSymbol(ownDeclarator), field)) + return; + + var member = site.Ancestors().OfType().FirstOrDefault() switch + { + MethodDeclarationSyntax m => m.Identifier.Text, + ConstructorDeclarationSyntax c => (c.Modifiers.Any(SyntaxKind.StaticKeyword) ? "static " : "") + c.Identifier.Text, + PropertyDeclarationSyntax p => p.Identifier.Text, + IndexerDeclarationSyntax => "this[]", + FieldDeclarationSyntax f => "initializer of " + string.Join(",", f.Declaration.Variables.Select(v => v.Identifier.Text)), + var other => other?.Kind().ToString() ?? "?", + }; + + var key = Key(field); + if (!Written.TryGetValue(key, out var sites)) + Written[key] = sites = new System.Collections.Generic.List(); + sites.Add(new WriteSite(site.SyntaxTree.FilePath, Line(site), member, kind)); + } + + private static int Line(SyntaxNode node) => node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + + private static string Key(IFieldSymbol field) => field.ContainingType.ToDisplayString() + "." + field.Name; + } +} diff --git a/src/Sharpy.Stdlib.Tests/Spy/SpyTestCollections.cs b/src/Sharpy.Stdlib.Tests/Spy/SpyTestCollections.cs index 592f8d10e..2fe45879f 100644 --- a/src/Sharpy.Stdlib.Tests/Spy/SpyTestCollections.cs +++ b/src/Sharpy.Stdlib.Tests/Spy/SpyTestCollections.cs @@ -30,6 +30,66 @@ public sealed class ProcessCwdCollection public sealed class PrngStateCollection { } + + // The module globals StdlibModuleGlobalConcurrencyTests (Conformance/) drives from several + // threads (#2250): each module's cells share a collection with the spy tests that set and then + // read the same global, so a cell never runs beside them. Like PrngState, none needs to run alone. + + /// Tests that set and read socket.setdefaulttimeout's process-wide default. + [CollectionDefinition("SocketDefaultTimeout")] + public sealed class SocketDefaultTimeoutCollection + { + } + + /// Tests that set calendar.setfirstweekday's module-wide first weekday and read it back. + [CollectionDefinition("CalendarFirstweekday")] + public sealed class CalendarFirstweekdayCollection + { + } + + /// Tests that write xml.register_namespace's process-wide registry. + [CollectionDefinition("XmlRegisteredNamespaces")] + public sealed class XmlRegisteredNamespacesCollection + { + } + + /// Tests that seed numpy.random and compare draws. + [CollectionDefinition("NumpyRandomState")] + public sealed class NumpyRandomStateCollection + { + } +} + +namespace Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests +{ + [Collection("SocketDefaultTimeout")] + public partial class SocketModuleTestsModuleTests + { + } +} + +namespace Sharpy.Stdlib.Tests.Spy.Calendar.CalendarTests +{ + [Collection("CalendarFirstweekday")] + public partial class CalendarTestsModuleTests + { + } +} + +namespace Sharpy.Stdlib.Tests.Spy.XML.XmlModuleTests +{ + [Collection("XmlRegisteredNamespaces")] + public partial class XmlModuleTestsModuleTests + { + } +} + +namespace Sharpy.Stdlib.Tests.Spy.Numpy.NumpyRandomTests +{ + [Collection("NumpyRandomState")] + public partial class NumpyRandomTestsModuleTests + { + } } namespace Sharpy.Stdlib.Tests.Spy.Os.OsModuleTests diff --git a/src/Sharpy.Stdlib.Tests/Spy/generated/socket_module_tests.cs b/src/Sharpy.Stdlib.Tests/Spy/generated/socket_module_tests.cs index cf674c2a5..a4064a81e 100644 --- a/src/Sharpy.Stdlib.Tests/Spy/generated/socket_module_tests.cs +++ b/src/Sharpy.Stdlib.Tests/Spy/generated/socket_module_tests.cs @@ -28,211 +28,299 @@ internal static double _ElapsedSince(double start) internal static string _TimeoutOutcome(global::System.Exception e) { -#line (678, 5) - (679, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (688, 5) - (689, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if ((object?)e is global::Sharpy.OverflowError) #line hidden { -#line (679, 9) - (679, 32) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (689, 9) - (689, 32) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return "OverflowError"; #line hidden } -#line (680, 5) - (681, 29) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (690, 5) - (691, 29) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if ((object?)e is global::Sharpy.ValueError) #line hidden { -#line (681, 9) - (681, 29) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (691, 9) - (691, 29) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return "ValueError"; #line hidden } -#line (682, 5) - (683, 31) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (692, 5) - (693, 31) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if ((object?)e is global::Sharpy.SocketModule.Error) #line hidden { -#line (683, 9) - (683, 31) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (693, 9) - (693, 31) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return "socket.error"; #line hidden } -#line (684, 5) - (684, 20) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (694, 5) - (694, 20) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return "other"; #line hidden } internal static bool _SameTimeout(double? actual, double? expected) { -#line (687, 5) - (688, 52) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (697, 5) - (698, 52) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (actual is null || expected is null) #line hidden { -#line (688, 9) - (688, 52) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (698, 9) - (698, 52) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return actual is null && expected is null; #line hidden } -#line (689, 5) - (689, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (699, 5) - (699, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" double a = actual.Value; -#line (690, 5) - (690, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (700, 5) - (700, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" double b = expected.Value; -#line (691, 5) - (691, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (701, 5) - (701, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return a == b; #line hidden } internal static int _ClosedLoopbackPort() { -#line (694, 5) - (694, 62) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (704, 5) - (704, 62) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var probe = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (695, 5) - (695, 33) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (705, 5) - (705, 33) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" probe.Bind(("127.0.0.1", 0)); -#line (696, 5) - (696, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (706, 5) - (706, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" int port = probe.Getsockname().Item2; -#line (697, 5) - (697, 18) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (707, 5) - (707, 18) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" probe.Close(); -#line (698, 5) - (698, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (708, 5) - (708, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" return port; #line hidden } + internal static string _ConnectOutcome(int port, double? timeout, bool viaCreateConnection) + { +#line (715, 5) - (715, 31) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + string outcome = "accepted"; +#line (716, 5) - (733, 18) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + if (viaCreateConnection) +#line hidden + { +#line (717, 9) - (723, 43) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + try +#line hidden + { +#line (718, 13) - (718, 95) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + global::Sharpy.SocketModule.Socket c = socket.CreateConnection(("127.0.0.1", port), timeout: timeout); +#line (719, 13) - (719, 22) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + c.Close(); +#line hidden + } + catch (global::System.Exception e) + { +#line (721, 13) - (721, 42) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._TimeoutOutcome(e); +#line (722, 13) - (723, 43) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + if ((object?)e is global::Sharpy.SocketModule.Timeout) +#line hidden + { +#line (723, 17) - (723, 43) 24 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + outcome = "socket.timeout"; +#line hidden + } + } + } + else + { +#line (725, 9) - (725, 62) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); +#line (726, 9) - (726, 30) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + s.Settimeout(timeout); +#line (727, 9) - (732, 43) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + try +#line hidden + { +#line (728, 13) - (728, 43) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + s.Connect(("127.0.0.1", port)); +#line hidden + } + catch (global::System.Exception e_1) + { +#line (730, 13) - (730, 42) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._TimeoutOutcome(e_1); +#line (731, 13) - (732, 43) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + if ((object?)e_1 is global::Sharpy.SocketModule.Timeout) +#line hidden + { +#line (732, 17) - (732, 43) 24 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + outcome = "socket.timeout"; +#line hidden + } + } + +#line (733, 9) - (733, 18) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + s.Close(); +#line hidden + } + +#line (734, 5) - (734, 20) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + return outcome; +#line hidden + } + + internal static string _ConnectToClosedPort(double? timeout, bool viaCreateConnection, int firstPort) + { +#line (743, 5) - (743, 22) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + int attempt = 0; +#line (744, 5) - (744, 31) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + string outcome = "accepted"; +#line (745, 5) - (752, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + while (attempt < 5) +#line hidden + { +#line (746, 9) - (746, 32) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + int port = firstPort; +#line (747, 9) - (748, 43) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + if (attempt > 0 || firstPort <= 0) +#line hidden + { +#line (748, 13) - (748, 43) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + port = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ClosedLoopbackPort(); +#line hidden + } + +#line (749, 9) - (749, 73) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectOutcome(port, timeout, viaCreateConnection); +#line (750, 9) - (751, 28) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + if (outcome != "accepted") +#line hidden + { +#line (751, 13) - (751, 28) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + return outcome; +#line hidden + } + +#line (752, 9) - (752, 21) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + attempt = attempt + 1; +#line hidden + } + +#line (753, 5) - (753, 20) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + return outcome; +#line hidden + } + internal static void _AssertTimeoutRule(double? value, string expected) { -#line (704, 5) - (704, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (759, 5) - (759, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" double? survivor = 2.0d; -#line (705, 5) - (706, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (760, 5) - (761, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (expected == "accepted") #line hidden { -#line (706, 9) - (706, 25) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (761, 9) - (761, 25) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" survivor = value; #line hidden } -#line (707, 5) - (707, 31) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (762, 5) - (762, 31) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" string outcome = "accepted"; -#line (708, 5) - (708, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (763, 5) - (763, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" socket.Setdefaulttimeout(2.0d); -#line (709, 5) - (721, 39) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (764, 5) - (776, 39) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" try #line hidden { -#line (710, 9) - (713, 42) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (765, 9) - (768, 42) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" try #line hidden { -#line (711, 13) - (711, 44) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (766, 13) - (766, 44) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" socket.Setdefaulttimeout(value); #line hidden } catch (global::System.Exception e) { -#line (713, 13) - (713, 42) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (768, 13) - (768, 42) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._TimeoutOutcome(e); #line hidden } -#line (714, 9) - (714, 36) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (769, 9) - (769, 36) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(outcome == expected)) #line hidden { throw new global::Sharpy.AssertionError(); } -#line (715, 9) - (715, 68) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (770, 9) - (770, 68) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._SameTimeout(socket.Getdefaulttimeout(), survivor))) #line hidden { throw new global::Sharpy.AssertionError(); } -#line (716, 9) - (716, 66) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (771, 9) - (771, 66) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var fresh = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (717, 9) - (717, 60) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (772, 9) - (772, 60) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._SameTimeout(fresh.Gettimeout(), survivor))) #line hidden { throw new global::Sharpy.AssertionError(); } -#line (718, 9) - (718, 22) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (773, 9) - (773, 22) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" fresh.Close(); #line hidden } finally { -#line (721, 9) - (721, 39) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (776, 9) - (776, 39) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" socket.Setdefaulttimeout(null); #line hidden } -#line (724, 5) - (724, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (779, 5) - (779, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (725, 5) - (725, 22) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (780, 5) - (780, 22) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Settimeout(2.0d); -#line (726, 5) - (726, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (781, 5) - (781, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" outcome = "accepted"; -#line (727, 5) - (730, 38) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (782, 5) - (785, 38) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" try #line hidden { -#line (728, 9) - (728, 28) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (783, 9) - (783, 28) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Settimeout(value); #line hidden } catch (global::System.Exception e_1) { -#line (730, 9) - (730, 38) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (785, 9) - (785, 38) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._TimeoutOutcome(e_1); #line hidden } -#line (731, 5) - (731, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (786, 5) - (786, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(outcome == expected)) #line hidden { throw new global::Sharpy.AssertionError(); } -#line (732, 5) - (732, 52) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (787, 5) - (787, 52) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._SameTimeout(s.Gettimeout(), survivor))) #line hidden { throw new global::Sharpy.AssertionError(); } -#line (733, 5) - (733, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (788, 5) - (788, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Close(); -#line (738, 5) - (738, 41) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - int port = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ClosedLoopbackPort(); -#line (739, 5) - (739, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - outcome = "accepted"; -#line (740, 5) - (745, 50) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - try -#line hidden - { -#line (741, 9) - (741, 89) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - global::Sharpy.SocketModule.Socket c = socket.CreateConnection(("127.0.0.1", port), timeout: value); -#line (742, 9) - (742, 18) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - c.Close(); -#line hidden - } - catch (global::System.Exception e_2) - { -#line (744, 9) - (744, 38) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._TimeoutOutcome(e_2); -#line (745, 9) - (745, 50) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - if (!(!((object?)e_2 is global::Sharpy.SocketModule.Timeout))) -#line hidden - { - throw new global::Sharpy.AssertionError(); - } - } - -#line (746, 5) - (749, 36) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (793, 5) - (793, 54) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + outcome = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectToClosedPort(value, true, 0); +#line (794, 5) - (797, 36) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (expected == "accepted") #line hidden { -#line (747, 9) - (747, 42) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (795, 9) - (795, 42) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(outcome == "socket.error")) #line hidden { @@ -241,7 +329,7 @@ internal static void _AssertTimeoutRule(double? value, string expected) } else { -#line (749, 9) - (749, 36) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (797, 9) - (797, 36) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" if (!(outcome == expected)) #line hidden { @@ -1258,75 +1346,78 @@ public void TestCreateConnectionWithTimeoutSucceedsToListeningServer() [Xunit.FactAttribute] public void TestConnectWithTimeoutRefusedRaisesErrorNotTimeout() { -#line (593, 5) - (593, 62) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - var probe = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (594, 5) - (594, 33) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - probe.Bind(("127.0.0.1", 0)); -#line (595, 5) - (595, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - var port = probe.Getsockname().Item2; -#line (596, 5) - (596, 18) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - probe.Close(); -#line (597, 5) - (597, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (598, 5) - (598, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - s.Settimeout(10.0d); -#line (599, 5) - (599, 26) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - bool caught = false; -#line (600, 5) - (600, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - bool isTimeout = false; -#line (601, 5) - (605, 51) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - try +#line (593, 5) - (593, 70) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + Xunit.Assert.Equal("socket.error", global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectToClosedPort(10.0d, false, 0)); #line hidden - { -#line (602, 9) - (602, 39) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - s.Connect(("127.0.0.1", port)); -#line hidden - } - catch (global::System.Exception e) - { -#line (604, 9) - (604, 45) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - caught = (object?)e is global::Sharpy.SocketModule.Error; -#line (605, 9) - (605, 51) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - isTimeout = (object?)e is global::Sharpy.SocketModule.Timeout; -#line hidden - } + } + [Xunit.FactAttribute] + public void TestConnectHelperReportsAcceptedForAListeningPort() + { +#line (600, 5) - (600, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + var server = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); +#line (601, 5) - (601, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + server.Bind(("127.0.0.1", 0)); +#line (602, 5) - (602, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + server.Listen(8); +#line (603, 5) - (603, 41) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + int port = server.Getsockname().Item2; +#line (604, 5) - (604, 62) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + Xunit.Assert.Equal("accepted", global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectOutcome(port, 10.0d, false)); +#line (605, 5) - (605, 61) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + Xunit.Assert.Equal("accepted", global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectOutcome(port, 10.0d, true)); #line (606, 5) - (606, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - Xunit.Assert.True(caught); -#line (607, 5) - (607, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - Xunit.Assert.False(isTimeout); -#line (608, 5) - (608, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" - s.Close(); + server.Close(); #line hidden } [Xunit.FactAttribute] - public void TestRecvWithTimeoutRaisesTimeout() + public void TestConnectToClosedPortRetriesAnAcceptedFirstPick() { #line (612, 5) - (612, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var server = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); #line (613, 5) - (613, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Bind(("127.0.0.1", 0)); #line (614, 5) - (614, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + server.Listen(8); +#line (615, 5) - (615, 41) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + int port = server.Getsockname().Item2; +#line (616, 5) - (616, 73) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + Xunit.Assert.Equal("socket.error", global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectToClosedPort(10.0d, false, port)); +#line (617, 5) - (617, 72) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + Xunit.Assert.Equal("socket.error", global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ConnectToClosedPort(10.0d, true, port)); +#line (618, 5) - (618, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + server.Close(); +#line hidden + } + + [Xunit.FactAttribute] + public void TestRecvWithTimeoutRaisesTimeout() + { +#line (622, 5) - (622, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + var server = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); +#line (623, 5) - (623, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" + server.Bind(("127.0.0.1", 0)); +#line (624, 5) - (624, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Listen(1); -#line (615, 5) - (615, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (625, 5) - (625, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var port = server.Getsockname().Item2; -#line (616, 5) - (616, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (626, 5) - (626, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var client = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (617, 5) - (617, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (627, 5) - (627, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Connect(("127.0.0.1", port)); -#line (618, 5) - (618, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (628, 5) - (628, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var (conn, __spy_underscore) = server.Accept(); -#line (619, 5) - (619, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (629, 5) - (629, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Settimeout(1.0d); -#line (620, 5) - (620, 37) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (630, 5) - (630, 37) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" double start = time.Monotonic(); -#line (621, 5) - (622, 24) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (631, 5) - (632, 24) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" bool __raised_3 = false; #line hidden try { -#line (622, 9) - (622, 24) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (632, 9) - (632, 24) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Recv(16); #line hidden } @@ -1337,17 +1428,17 @@ public void TestRecvWithTimeoutRaisesTimeout() if (!__raised_3) throw new global::Sharpy.AssertionError("Expected timeout to be raised, but no exception was raised"); -#line (623, 5) - (623, 44) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (633, 5) - (633, 44) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" double elapsed = global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ElapsedSince(start); -#line (624, 5) - (624, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (634, 5) - (634, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(elapsed >= 0.5d); -#line (625, 5) - (625, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (635, 5) - (635, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(elapsed < 30.0d); -#line (626, 5) - (626, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (636, 5) - (636, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" conn.Close(); -#line (627, 5) - (627, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (637, 5) - (637, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Close(); -#line (628, 5) - (628, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (638, 5) - (638, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Close(); #line hidden } @@ -1355,57 +1446,57 @@ public void TestRecvWithTimeoutRaisesTimeout() [Xunit.FactAttribute] public void TestSendWithTimeoutRaisesTimeoutWhenPeerDoesNotRead() { -#line (632, 5) - (632, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (642, 5) - (642, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var server = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (633, 5) - (633, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (643, 5) - (643, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Bind(("127.0.0.1", 0)); -#line (634, 5) - (634, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (644, 5) - (644, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Listen(1); -#line (635, 5) - (635, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (645, 5) - (645, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var port = server.Getsockname().Item2; -#line (636, 5) - (636, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (646, 5) - (646, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var client = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (637, 5) - (637, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (647, 5) - (647, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Connect(("127.0.0.1", port)); -#line (638, 5) - (638, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (648, 5) - (648, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var (conn, __spy_underscore) = server.Accept(); -#line (639, 5) - (639, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (649, 5) - (649, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Settimeout(1.0d); -#line (640, 5) - (640, 33) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (650, 5) - (650, 33) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Sharpy.Bytes chunk = new Sharpy.Bytes(new byte[] { 120 }) * 65536; -#line (641, 5) - (641, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (651, 5) - (651, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" bool isTimeout = false; -#line (642, 5) - (642, 37) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (652, 5) - (652, 37) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" double start = time.Monotonic(); -#line (643, 5) - (647, 51) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (653, 5) - (657, 51) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" try #line hidden { -#line (644, 9) - (645, 31) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (654, 9) - (655, 31) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" while (global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ElapsedSince(start) < 30.0d) #line hidden { -#line (645, 13) - (645, 31) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (655, 13) - (655, 31) 20 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Send(chunk); #line hidden } } catch (global::System.Exception e) { -#line (647, 9) - (647, 51) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (657, 9) - (657, 51) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" isTimeout = (object?)e is global::Sharpy.SocketModule.Timeout; #line hidden } -#line (648, 5) - (648, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (658, 5) - (658, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(isTimeout); -#line (649, 5) - (649, 41) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (659, 5) - (659, 41) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._ElapsedSince(start) < 30.0d); -#line (650, 5) - (650, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (660, 5) - (660, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" conn.Close(); -#line (651, 5) - (651, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (661, 5) - (661, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Close(); -#line (652, 5) - (652, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (662, 5) - (662, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Close(); #line hidden } @@ -1413,14 +1504,14 @@ public void TestSendWithTimeoutRaisesTimeoutWhenPeerDoesNotRead() [Xunit.FactAttribute] public void TestSettimeoutNegativeRaisesValueError() { -#line (656, 5) - (656, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (666, 5) - (666, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (657, 5) - (658, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (667, 5) - (668, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" bool __raised_4 = false; #line hidden try { -#line (658, 9) - (658, 27) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (668, 9) - (668, 27) 16 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Settimeout(-1.0d); #line hidden } @@ -1431,9 +1522,9 @@ public void TestSettimeoutNegativeRaisesValueError() if (!__raised_4) throw new global::Sharpy.AssertionError("Expected ValueError to be raised, but no exception was raised"); -#line (659, 5) - (659, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (669, 5) - (669, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.Null(s.Gettimeout()); -#line (660, 5) - (660, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (670, 5) - (670, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Close(); #line hidden } @@ -1441,13 +1532,13 @@ public void TestSettimeoutNegativeRaisesValueError() [Xunit.FactAttribute] public void TestTimeoutRuleAcceptsNoneZeroAndPositive() { -#line (753, 5) - (753, 43) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (801, 5) - (801, 43) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(null, "accepted"); -#line (754, 5) - (754, 42) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (802, 5) - (802, 42) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(0.0d, "accepted"); -#line (755, 5) - (755, 43) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (803, 5) - (803, 43) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(-0.0d, "accepted"); -#line (756, 5) - (756, 43) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (804, 5) - (804, 43) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(10.5d, "accepted"); #line hidden } @@ -1455,9 +1546,9 @@ public void TestTimeoutRuleAcceptsNoneZeroAndPositive() [Xunit.FactAttribute] public void TestTimeoutRuleAcceptsVeryLargeWithinInt64Nanoseconds() { -#line (760, 5) - (760, 42) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (808, 5) - (808, 42) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(1e6d, "accepted"); -#line (761, 5) - (761, 44) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (809, 5) - (809, 44) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(9.2e9d, "accepted"); #line hidden } @@ -1465,9 +1556,9 @@ public void TestTimeoutRuleAcceptsVeryLargeWithinInt64Nanoseconds() [Xunit.FactAttribute] public void TestTimeoutRuleRefusesNegativeWithValueError() { -#line (765, 5) - (765, 45) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (813, 5) - (813, 45) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(-1.0d, "ValueError"); -#line (766, 5) - (766, 47) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (814, 5) - (814, 47) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(-1e-10d, "ValueError"); #line hidden } @@ -1475,7 +1566,7 @@ public void TestTimeoutRuleRefusesNegativeWithValueError() [Xunit.FactAttribute] public void TestTimeoutRuleRefusesNanWithValueError() { -#line (770, 5) - (770, 53) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (818, 5) - (818, 53) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(global::Sharpy.Builtins.Float("nan"), "ValueError"); #line hidden } @@ -1483,15 +1574,15 @@ public void TestTimeoutRuleRefusesNanWithValueError() [Xunit.FactAttribute] public void TestTimeoutRuleRefusesInt64NanosecondOverflowWithOverflowError() { -#line (774, 5) - (774, 56) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (822, 5) - (822, 56) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(global::Sharpy.Builtins.Float("inf"), "OverflowError"); -#line (775, 5) - (775, 57) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (823, 5) - (823, 57) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(global::Sharpy.Builtins.Float("-inf"), "OverflowError"); -#line (776, 5) - (776, 49) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (824, 5) - (824, 49) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(9.3e9d, "OverflowError"); -#line (777, 5) - (777, 49) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (825, 5) - (825, 49) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(1e300d, "OverflowError"); -#line (778, 5) - (778, 50) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (826, 5) - (826, 50) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::Sharpy.Stdlib.Tests.Spy.Socket.SocketModuleTests.SocketModuleTestsModule._AssertTimeoutRule(-1e300d, "OverflowError"); #line hidden } @@ -1499,37 +1590,37 @@ public void TestTimeoutRuleRefusesInt64NanosecondOverflowWithOverflowError() [Xunit.FactAttribute] public void TestVeryLargeTimeoutStillGovernsLoopbackConnectAndRecv() { -#line (784, 5) - (784, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (832, 5) - (832, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var server = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (785, 5) - (785, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (833, 5) - (833, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Bind(("127.0.0.1", 0)); -#line (786, 5) - (786, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (834, 5) - (834, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Listen(1); -#line (787, 5) - (787, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (835, 5) - (835, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var port = server.Getsockname().Item2; -#line (788, 5) - (788, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (836, 5) - (836, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var client = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (789, 5) - (789, 29) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (837, 5) - (837, 29) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Settimeout(9.2e9d); -#line (790, 5) - (790, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (838, 5) - (838, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Connect(("127.0.0.1", port)); -#line (791, 5) - (791, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (839, 5) - (839, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var (conn, __spy_underscore) = server.Accept(); -#line (792, 5) - (792, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (840, 5) - (840, 27) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" conn.Settimeout(9.2e9d); -#line (793, 5) - (793, 38) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (841, 5) - (841, 38) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" int sent = client.Send(new Sharpy.Bytes(new byte[] { 112, 105, 110, 103 })); -#line (794, 5) - (794, 22) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (842, 5) - (842, 22) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.Equal(4, sent); -#line (795, 5) - (795, 37) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (843, 5) - (843, 37) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Sharpy.Bytes received = conn.Recv(16); -#line (796, 5) - (796, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (844, 5) - (844, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.Equal(new Sharpy.Bytes(new byte[] { 112, 105, 110, 103 }), received); -#line (797, 5) - (797, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (845, 5) - (845, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" conn.Close(); -#line (798, 5) - (798, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (846, 5) - (846, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Close(); -#line (799, 5) - (799, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (847, 5) - (847, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Close(); #line hidden } @@ -1537,17 +1628,17 @@ public void TestVeryLargeTimeoutStillGovernsLoopbackConnectAndRecv() [Xunit.FactAttribute] public void TestStrContainsSocketInfo() { -#line (805, 5) - (805, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (853, 5) - (853, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (806, 5) - (806, 24) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (854, 5) - (854, 24) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" string text = global::Sharpy.Builtins.Str(s); -#line (807, 5) - (807, 29) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (855, 5) - (855, 29) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(text.Contains("socket")); -#line (808, 5) - (808, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (856, 5) - (856, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(text.Contains("family=")); -#line (809, 5) - (809, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (857, 5) - (857, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(text.Contains("type=")); -#line (810, 5) - (810, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (858, 5) - (858, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Close(); #line hidden } @@ -1555,33 +1646,33 @@ public void TestStrContainsSocketInfo() [Xunit.FactAttribute] public void TestGetpeernameAfterConnectReturnsRemoteAddr() { -#line (816, 5) - (816, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (864, 5) - (864, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var server = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (817, 5) - (817, 65) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (865, 5) - (865, 65) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Setsockopt(global::Sharpy.SocketModule.SocketModuleModule.SOL_SOCKET, global::Sharpy.SocketModule.SocketModuleModule.SO_REUSEADDR, 1); -#line (818, 5) - (818, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (866, 5) - (866, 34) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Bind(("127.0.0.1", 0)); -#line (819, 5) - (819, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (867, 5) - (867, 21) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Listen(1); -#line (820, 5) - (820, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (868, 5) - (868, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var port = server.Getsockname().Item2; -#line (822, 5) - (822, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (870, 5) - (870, 63) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var client = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (823, 5) - (823, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (871, 5) - (871, 40) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Connect(("127.0.0.1", port)); -#line (825, 5) - (825, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (873, 5) - (873, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" global::System.ValueTuple peer = client.Getpeername(); -#line (826, 5) - (826, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (874, 5) - (874, 35) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.Equal("127.0.0.1", peer.Item1); -#line (827, 5) - (827, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (875, 5) - (875, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.Equal(port, peer.Item2); -#line (829, 5) - (829, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (877, 5) - (877, 30) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var (conn, __spy_underscore) = server.Accept(); -#line (830, 5) - (830, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (878, 5) - (878, 17) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" conn.Close(); -#line (831, 5) - (831, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (879, 5) - (879, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" client.Close(); -#line (832, 5) - (832, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (880, 5) - (880, 19) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" server.Close(); #line hidden } @@ -1589,13 +1680,13 @@ public void TestGetpeernameAfterConnectReturnsRemoteAddr() [Xunit.FactAttribute] public void TestSetblockingFalseSetsNonBlocking() { -#line (838, 5) - (838, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (886, 5) - (886, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (839, 5) - (839, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (887, 5) - (887, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Setblocking(false); -#line (840, 5) - (840, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (888, 5) - (888, 32) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.False(s.Getblocking()); -#line (841, 5) - (841, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (889, 5) - (889, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Close(); #line hidden } @@ -1603,15 +1694,15 @@ public void TestSetblockingFalseSetsNonBlocking() [Xunit.FactAttribute] public void TestSetblockingTrueSetsBlocking() { -#line (845, 5) - (845, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (893, 5) - (893, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var s = new global::Sharpy.SocketModule.Socket(global::Sharpy.SocketModule.SocketModuleModule.AF_INET, global::Sharpy.SocketModule.SocketModuleModule.SOCK_STREAM); -#line (846, 5) - (846, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (894, 5) - (894, 25) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Setblocking(false); -#line (847, 5) - (847, 24) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (895, 5) - (895, 24) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Setblocking(true); -#line (848, 5) - (848, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (896, 5) - (896, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.True(s.Getblocking()); -#line (849, 5) - (849, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (897, 5) - (897, 14) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" s.Close(); #line hidden } @@ -1619,11 +1710,11 @@ public void TestSetblockingTrueSetsBlocking() [Xunit.FactAttribute] public void TestGetnameinfoLocalhostReturnsHostAndService() { -#line (855, 5) - (855, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (903, 5) - (903, 58) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" var (host, service) = socket.Getnameinfo(("127.0.0.1", 80)); -#line (856, 5) - (856, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (904, 5) - (904, 23) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.NotEqual("", host); -#line (857, 5) - (857, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" +#line (905, 5) - (905, 28) 12 "src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy" Xunit.Assert.Equal("80", service); #line hidden } diff --git a/src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy b/src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy index d2a9773eb..c72dc6411 100644 --- a/src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy +++ b/src/Sharpy.Stdlib.Tests/Spy/socket/socket_module_tests.spy @@ -590,22 +590,32 @@ def test_create_connection_with_timeout_succeeds_to_listening_server(): @test def test_connect_with_timeout_refused_raises_error_not_timeout(): # python3: ConnectionRefusedError (an OSError, not a TimeoutError), at once. - probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - probe.bind(("127.0.0.1", 0)) - port = probe.getsockname()[1] - probe.close() - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(10.0) - caught: bool = False - is_timeout: bool = False - try: - s.connect(("127.0.0.1", port)) - except Exception as e: - caught = isinstance(e, socket.error) - is_timeout = isinstance(e, socket.timeout) - assert caught - assert not is_timeout - s.close() + assert _connect_to_closed_port(10.0, False, 0) == "socket.error" + +@test +def test_connect_helper_reports_accepted_for_a_listening_port(): + # Positive control for _connect_to_closed_port's retry: a genuine accept is + # reported as "accepted", never masked (python3: four connects to one + # listen(8) socket that never accepts all succeed). + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(8) + port: int = server.getsockname()[1] + assert _connect_outcome(port, 10.0, False) == "accepted" + assert _connect_outcome(port, 10.0, True) == "accepted" + server.close() + +@test +def test_connect_to_closed_port_retries_an_accepted_first_pick(): + # The retry arm: a first pick that accepts (the symptom of a re-taken + # ephemeral port) is replaced by a fresh closed port, which refuses. + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("127.0.0.1", 0)) + server.listen(8) + port: int = server.getsockname()[1] + assert _connect_to_closed_port(10.0, False, port) == "socket.error" + assert _connect_to_closed_port(10.0, True, port) == "socket.error" + server.close() @test def test_recv_with_timeout_raises_timeout(): @@ -697,6 +707,51 @@ def _closed_loopback_port() -> int: probe.close() return port +def _connect_outcome(port: int, timeout: float | None, via_create_connection: bool) -> str: + # One connect to 127.0.0.1:port — through create_connection(timeout=) or + # settimeout + connect — classified as "accepted", "socket.timeout", or + # _timeout_outcome's name for the exception (a refused timeout value raises + # before any connect). + outcome: str = "accepted" + if via_create_connection: + try: + c: socket.socket = socket.create_connection(("127.0.0.1", port), timeout=timeout) + c.close() + except Exception as e: + outcome = _timeout_outcome(e) + if isinstance(e, socket.timeout): + outcome = "socket.timeout" + else: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect(("127.0.0.1", port)) + except Exception as e: + outcome = _timeout_outcome(e) + if isinstance(e, socket.timeout): + outcome = "socket.timeout" + s.close() + return outcome + +def _connect_to_closed_port(timeout: float | None, via_create_connection: bool, first_port: int) -> str: + # A closed port is picked by binding port 0 and closing it, so another + # process can take it before the connect; the only symptom is a connect that + # is accepted. The race cannot be prevented (a bound-but-not-listening port + # TIMES OUT on macOS, so the probe cannot hold it), so an accepted connect + # picks another port, up to five attempts (#2250). first_port > 0 replaces + # the first pick: the retry arm's test double. + attempt: int = 0 + outcome: str = "accepted" + while attempt < 5: + port: int = first_port + if attempt > 0 or first_port <= 0: + port = _closed_loopback_port() + outcome = _connect_outcome(port, timeout, via_create_connection) + if outcome != "accepted": + return outcome + attempt += 1 + return outcome + def _assert_timeout_rule(value: float | None, expected: str) -> None: # setdefaulttimeout: the outcome is decided at the call. The next socket() # construction never raises and sees the accepted value, or the previous @@ -735,14 +790,7 @@ def _assert_timeout_rule(value: float | None, expected: str) -> None: # create_connection(timeout=): a refused value raises before any connect; # an accepted one reaches the connect, which the closed port refuses with # socket.error (never socket.timeout: the refusal is immediate). - port: int = _closed_loopback_port() - outcome = "accepted" - try: - c: socket.socket = socket.create_connection(("127.0.0.1", port), timeout=value) - c.close() - except Exception as e: - outcome = _timeout_outcome(e) - assert not isinstance(e, socket.timeout) + outcome = _connect_to_closed_port(value, True, 0) if expected == "accepted": assert outcome == "socket.error" else: diff --git a/src/Sharpy.Stdlib/Numpy/NumpyRandom.cs b/src/Sharpy.Stdlib/Numpy/NumpyRandom.cs index 17d884910..1a80d09c4 100644 --- a/src/Sharpy.Stdlib/Numpy/NumpyRandom.cs +++ b/src/Sharpy.Stdlib/Numpy/NumpyRandom.cs @@ -8,26 +8,32 @@ namespace Sharpy /// generation backed by and Math.NET Numerics distributions. /// /// - /// The underlying random number generator is thread-static - /// — each thread sees its own instance. This matches the safer .NET model while remaining - /// compatible with Python's non-thread-safe global RNG behavior. Call - /// on a thread to obtain a reproducible sequence on that thread. + /// The underlying random number generator is ONE process-wide + /// behind a lock, as numpy's global RandomState is process-wide: with numpy 2.5.1, + /// np.random.seed(7) on the main thread and then np.random.rand() on a worker + /// thread returns the main thread's re-seeded draw (0.07630828937395717 both). A + /// on any thread is therefore seen by the next draw on every thread, + /// and each call holds the lock for its whole draw, as RandomState's methods hold its + /// lock (#2250). /// [SharpyModule("numpy.random")] public static class NumpyRandom { - [ThreadStatic] - private static System.Random? _rng; + private static System.Random _rng = new System.Random(); - private static System.Random Rng => _rng ??= new System.Random(); + /// Guards every read and write of (#2250). + private static readonly object RngLock = new object(); /// - /// Seed the thread-local random number generator with . + /// Seed the process-wide random number generator with . /// /// Seed value for the underlying . public static void Seed(int seed) { - _rng = new System.Random(seed); + lock (RngLock) + { + _rng = new System.Random(seed); + } } /// @@ -43,10 +49,13 @@ public static NdArray Rand(params int[] shape) int size = ProductOfShape(shape); var data = new double[size]; - var rng = Rng; - for (int i = 0; i < size; i++) + lock (RngLock) { - data[i] = rng.NextDouble(); + var rng = _rng; + for (int i = 0; i < size; i++) + { + data[i] = rng.NextDouble(); + } } return new NdArray(data, shape); @@ -65,10 +74,13 @@ public static NdArray Randn(params int[] shape) int size = ProductOfShape(shape); var data = new double[size]; - var rng = Rng; - for (int i = 0; i < size; i++) + lock (RngLock) { - data[i] = MathNet.Numerics.Distributions.Normal.Sample(rng, 0.0, 1.0); + var rng = _rng; + for (int i = 0; i < size; i++) + { + data[i] = MathNet.Numerics.Distributions.Normal.Sample(rng, 0.0, 1.0); + } } return new NdArray(data, shape); @@ -95,10 +107,13 @@ public static NdArray Randint(int low, int high, int[] shape) int size = ProductOfShape(shape); var data = new int[size]; - var rng = Rng; - for (int i = 0; i < size; i++) + lock (RngLock) { - data[i] = rng.Next(low, high); + var rng = _rng; + for (int i = 0; i < size; i++) + { + data[i] = rng.Next(low, high); + } } return new NdArray(data, shape); @@ -125,10 +140,13 @@ public static NdArray Normal(double loc, double scale, int[] shape) int size = ProductOfShape(shape); var data = new double[size]; - var rng = Rng; - for (int i = 0; i < size; i++) + lock (RngLock) { - data[i] = MathNet.Numerics.Distributions.Normal.Sample(rng, loc, scale); + var rng = _rng; + for (int i = 0; i < size; i++) + { + data[i] = MathNet.Numerics.Distributions.Normal.Sample(rng, loc, scale); + } } return new NdArray(data, shape); @@ -155,10 +173,13 @@ public static NdArray Uniform(double low, double high, int[] shape) int size = ProductOfShape(shape); var data = new double[size]; - var rng = Rng; - for (int i = 0; i < size; i++) + lock (RngLock) { - data[i] = ContinuousUniform.Sample(rng, low, high); + var rng = _rng; + for (int i = 0; i < size; i++) + { + data[i] = ContinuousUniform.Sample(rng, low, high); + } } return new NdArray(data, shape); @@ -206,7 +227,6 @@ public static NdArray Choice(NdArray a, int size, bool replace = true) } var data = new T[size]; - var rng = Rng; // Materialize source values via flat stride-aware indexing so views work correctly. var source = new T[n]; @@ -215,29 +235,33 @@ public static NdArray Choice(NdArray a, int size, bool replace = true) source[i] = a._data[a._offset + i * a._strides[0]]; } - if (replace) + lock (RngLock) { - for (int i = 0; i < size; i++) + var rng = _rng; + if (replace) { - data[i] = source[rng.Next(n)]; + for (int i = 0; i < size; i++) + { + data[i] = source[rng.Next(n)]; + } } - } - else - { - // Partial Fisher-Yates: draw without replacement by shuffling the first `size` slots. - var indices = new int[n]; - for (int i = 0; i < n; i++) + else { - indices[i] = i; - } + // Partial Fisher-Yates: draw without replacement by shuffling the first `size` slots. + var indices = new int[n]; + for (int i = 0; i < n; i++) + { + indices[i] = i; + } - for (int i = 0; i < size; i++) - { - int j = rng.Next(i, n); - int tmp = indices[i]; - indices[i] = indices[j]; - indices[j] = tmp; - data[i] = source[indices[i]]; + for (int i = 0; i < size; i++) + { + int j = rng.Next(i, n); + int tmp = indices[i]; + indices[i] = indices[j]; + indices[j] = tmp; + data[i] = source[indices[i]]; + } } } @@ -278,29 +302,32 @@ public static void Shuffle(NdArray a) where T : struct, IEquatable int stride0 = a._strides[0]; int offset = a._offset; - var rng = Rng; - - // Fisher-Yates swap of full blocks along axis 0. - for (int i = n - 1; i > 0; i--) + lock (RngLock) { - int j = rng.Next(i + 1); - if (i == j) + var rng = _rng; + + // Fisher-Yates swap of full blocks along axis 0. + for (int i = n - 1; i > 0; i--) { - continue; - } + int j = rng.Next(i + 1); + if (i == j) + { + continue; + } - int srcBase = offset + i * stride0; - int dstBase = offset + j * stride0; + int srcBase = offset + i * stride0; + int dstBase = offset + j * stride0; - if (a.Ndim == 1) - { - T tmp = a._data[srcBase]; - a._data[srcBase] = a._data[dstBase]; - a._data[dstBase] = tmp; - } - else - { - SwapBlock(a, srcBase, dstBase, blockSize); + if (a.Ndim == 1) + { + T tmp = a._data[srcBase]; + a._data[srcBase] = a._data[dstBase]; + a._data[dstBase] = tmp; + } + else + { + SwapBlock(a, srcBase, dstBase, blockSize); + } } } } diff --git a/src/Sharpy.Stdlib/Random/Random.cs b/src/Sharpy.Stdlib/Random/Random.cs index 477af0b71..8dc336cf7 100644 --- a/src/Sharpy.Stdlib/Random/Random.cs +++ b/src/Sharpy.Stdlib/Random/Random.cs @@ -16,12 +16,24 @@ namespace Sharpy.RandomModule public static partial class RandomModuleModule { public static global::System.Random _Random = new global::System.Random(); + public static global::Sharpy.Lock _Lock = new global::Sharpy.Lock(); /// /// Initialize the random number generator with the given seed. /// public static void Seed(int s) { - _Random = new global::System.Random(s); + { + var __ctx_0 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_0.Enter(); + try + { + _Random = new global::System.Random(s); + } + finally + { + __ctx_0.Exit(); + } + } } /// @@ -29,7 +41,18 @@ public static void Seed(int s) /// public static double NextDouble() { - return global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble(); + { + var __ctx_1 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_1.Enter(); + try + { + return global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble(); + } + finally + { + __ctx_1.Exit(); + } + } } /// @@ -37,7 +60,18 @@ public static double NextDouble() /// public static int Randint(int a, int b) { - return global::Sharpy.RandomModule.RandomModuleModule._Random.Next(a, b + 1); + { + var __ctx_2 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_2.Enter(); + try + { + return global::Sharpy.RandomModule.RandomModuleModule._Random.Next(a, b + 1); + } + finally + { + __ctx_2.Exit(); + } + } } /// @@ -45,7 +79,18 @@ public static int Randint(int a, int b) /// public static double Uniform(double a, double b) { - return a + (global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble() * (b - a)); + { + var __ctx_3 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_3.Enter(); + try + { + return a + (global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble() * (b - a)); + } + finally + { + __ctx_3.Exit(); + } + } } /// @@ -58,7 +103,20 @@ public static T Choice(Sharpy.List seq) throw new global::Sharpy.IndexError("Cannot choose from an empty sequence"); } - int index = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(global::Sharpy.Builtins.Len(seq)); + int index = 0; + { + var __ctx_4 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_4.Enter(); + try + { + index = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(global::Sharpy.Builtins.Len(seq)); + } + finally + { + __ctx_4.Exit(); + } + } + return seq[index]; } @@ -76,7 +134,20 @@ public static void Shuffle(Sharpy.List x) int i = n - 1; while (i > 0) { - int j = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(i + 1); + int j = 0; + { + var __ctx_5 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_5.Enter(); + try + { + j = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(i + 1); + } + finally + { + __ctx_5.Exit(); + } + } + T temp = x[i]; x[i] = x[j]; x[j] = temp; @@ -94,7 +165,18 @@ public static int Randrange(int stop) throw new global::Sharpy.ValueError(FormattableString.Invariant($"empty range for randrange() (0, {(global::Sharpy.Builtins.Str(stop))})")); } - return global::Sharpy.RandomModule.RandomModuleModule._Random.Next(stop); + { + var __ctx_6 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_6.Enter(); + try + { + return global::Sharpy.RandomModule.RandomModuleModule._Random.Next(stop); + } + finally + { + __ctx_6.Exit(); + } + } } /// @@ -115,7 +197,18 @@ public static int Randrange(int start, int stop, int step = 1) throw new global::Sharpy.ValueError(FormattableString.Invariant($"empty range for randrange() ({(global::Sharpy.Builtins.Str(start))}, {(global::Sharpy.Builtins.Str(stop))}, {(global::Sharpy.Builtins.Str(step))})")); } - return start + global::Sharpy.RandomModule.RandomModuleModule._Random.Next(width); + { + var __ctx_7 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_7.Enter(); + try + { + return start + global::Sharpy.RandomModule.RandomModuleModule._Random.Next(width); + } + finally + { + __ctx_7.Exit(); + } + } } int n = 0; @@ -133,7 +226,18 @@ public static int Randrange(int start, int stop, int step = 1) throw new global::Sharpy.ValueError(FormattableString.Invariant($"empty range for randrange() ({(global::Sharpy.Builtins.Str(start))}, {(global::Sharpy.Builtins.Str(stop))}, {(global::Sharpy.Builtins.Str(step))})")); } - return start + step * global::Sharpy.RandomModule.RandomModuleModule._Random.Next(n); + { + var __ctx_8 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_8.Enter(); + try + { + return start + step * global::Sharpy.RandomModule.RandomModuleModule._Random.Next(n); + } + finally + { + __ctx_8.Exit(); + } + } } /// @@ -141,8 +245,22 @@ public static int Randrange(int start, int stop, int step = 1) /// public static double Gauss(double mu, double sigma) { - double u1 = global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble(); - double u2 = global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble(); + double u1 = 0.0d; + double u2 = 0.0d; + { + var __ctx_9 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_9.Enter(); + try + { + u1 = global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble(); + u2 = global::Sharpy.RandomModule.RandomModuleModule._Random.NextDouble(); + } + finally + { + __ctx_9.Exit(); + } + } + double z0 = global::System.Math.Sqrt(-2.0d * global::System.Math.Log(u1)) * global::System.Math.Cos(2.0d * 3.141592653589793d * u2); return mu + sigma * z0; } @@ -167,7 +285,18 @@ public static int Getrandbits(int k) throw new global::Sharpy.ValueError("number of bits must be <= 30 for int return"); } - return global::Sharpy.RandomModule.RandomModuleModule._Random.Next(1 << k); + { + var __ctx_10 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_10.Enter(); + try + { + return global::Sharpy.RandomModule.RandomModuleModule._Random.Next(1 << k); + } + finally + { + __ctx_10.Exit(); + } + } } /// @@ -187,7 +316,21 @@ public static Sharpy.List Choices(Sharpy.List population, int k = 1) int i = 0; while (i < k) { - result.Append(population[global::Sharpy.RandomModule.RandomModuleModule._Random.Next(n)]); + int index = 0; + { + var __ctx_11 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_11.Enter(); + try + { + index = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(n); + } + finally + { + __ctx_11.Exit(); + } + } + + result.Append(population[index]); i = i + 1; } @@ -214,7 +357,20 @@ public static Sharpy.List Sample(Sharpy.List population, int k) int i = 0; while (i < k) { - int j = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(i, n); + int j = 0; + { + var __ctx_12 = global::Sharpy.RandomModule.RandomModuleModule._Lock; + __ctx_12.Enter(); + try + { + j = global::Sharpy.RandomModule.RandomModuleModule._Random.Next(i, n); + } + finally + { + __ctx_12.Exit(); + } + } + T temp = pool[i]; pool[i] = pool[j]; pool[j] = temp; diff --git a/src/Sharpy.Stdlib/Socket/SocketModule.cs b/src/Sharpy.Stdlib/Socket/SocketModule.cs index 18bb11f70..59515ddaf 100644 --- a/src/Sharpy.Stdlib/Socket/SocketModule.cs +++ b/src/Sharpy.Stdlib/Socket/SocketModule.cs @@ -36,6 +36,7 @@ public static partial class SocketModuleModule public static int SHUT_RDWR = (int)global::System.Net.Sockets.SocketShutdown.Both; public static int SOMAXCONN = 128; public static double? _DefaultTimeout = null; + public static global::Sharpy.Lock _Lock = new global::Sharpy.Lock(); /// /// Refuse a timeout value python3 refuses, at the call that receives it (#2228). /// The one rule shared by every timeout-accepting entry point (settimeout, @@ -74,7 +75,18 @@ internal static void _CheckTimeout(double? timeout) /// public static double? Getdefaulttimeout() { - return global::Sharpy.SocketModule.SocketModuleModule._DefaultTimeout; + { + var __ctx_1 = global::Sharpy.SocketModule.SocketModuleModule._Lock; + __ctx_1.Enter(); + try + { + return global::Sharpy.SocketModule.SocketModuleModule._DefaultTimeout; + } + finally + { + __ctx_1.Exit(); + } + } } /// @@ -83,7 +95,18 @@ internal static void _CheckTimeout(double? timeout) public static void Setdefaulttimeout(double? timeout) { global::Sharpy.SocketModule.SocketModuleModule._CheckTimeout(timeout); - _DefaultTimeout = timeout; + { + var __ctx_2 = global::Sharpy.SocketModule.SocketModuleModule._Lock; + __ctx_2.Enter(); + try + { + _DefaultTimeout = timeout; + } + finally + { + __ctx_2.Exit(); + } + } } /// @@ -92,7 +115,21 @@ public static void Setdefaulttimeout(double? timeout) /// public static global::Sharpy.SocketModule.Socket CreateConnection((string host, int port) address) { - return global::Sharpy.SocketModule.SocketModuleModule.CreateConnection(address, global::Sharpy.SocketModule.SocketModuleModule._DefaultTimeout); + double? @default = null; + { + var __ctx_3 = global::Sharpy.SocketModule.SocketModuleModule._Lock; + __ctx_3.Enter(); + try + { + @default = global::Sharpy.SocketModule.SocketModuleModule._DefaultTimeout; + } + finally + { + __ctx_3.Exit(); + } + } + + return global::Sharpy.SocketModule.SocketModuleModule.CreateConnection(address, @default); } /// @@ -819,7 +856,20 @@ public Socket(int family = 2, int sockType = 1, int proto = 0) { this._Socket = new global::System.Net.Sockets.Socket((global::System.Net.Sockets.AddressFamily)family, (global::System.Net.Sockets.SocketType)sockType, (global::System.Net.Sockets.ProtocolType)proto); this._Timeout = null; - double? @default = global::Sharpy.SocketModule.SocketModuleModule._DefaultTimeout; + double? @default = null; + { + var __ctx_0 = global::Sharpy.SocketModule.SocketModuleModule._Lock; + __ctx_0.Enter(); + try + { + @default = global::Sharpy.SocketModule.SocketModuleModule._DefaultTimeout; + } + finally + { + __ctx_0.Exit(); + } + } + if (@default is not null) { this.Settimeout(@default.Value); diff --git a/src/Sharpy.Stdlib/Xml/XmlFunctions.cs b/src/Sharpy.Stdlib/Xml/XmlFunctions.cs index c54610f5e..ec128e20c 100644 --- a/src/Sharpy.Stdlib/Xml/XmlFunctions.cs +++ b/src/Sharpy.Stdlib/Xml/XmlFunctions.cs @@ -170,12 +170,24 @@ public static void RegisterNamespace(string prefix, string uri) throw new TypeError("expected str, got NoneType"); } - _registeredNamespaces[prefix] = uri; + lock (RegisteredNamespacesLock) + { + _registeredNamespaces[prefix] = uri; + } } + /// + /// The process-wide prefix registry (CPython's ElementTree._namespace_map, a module + /// global every thread sees). Every read and write holds : + /// CPython mutates the dict under the GIL, and a + /// written concurrently loses entries or throws (#2250). + /// internal static readonly System.Collections.Generic.Dictionary _registeredNamespaces = new System.Collections.Generic.Dictionary(); + /// Guards every read and write of (#2250). + internal static readonly object RegisteredNamespacesLock = new object(); + internal const string CommentTag = "{sharpy:internal}comment"; internal const string PITagPrefix = "{sharpy:internal}pi-"; diff --git a/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Random.csproj b/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Random.csproj index 3d82ad4f4..35eedb0f7 100644 --- a/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Random.csproj +++ b/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Random.csproj @@ -8,4 +8,9 @@ + + + + + diff --git a/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Socket.csproj b/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Socket.csproj index a5dd1da32..852a20301 100644 --- a/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Socket.csproj +++ b/src/Sharpy.Stdlib/modules/Sharpy.Stdlib.Socket.csproj @@ -8,4 +8,9 @@ + + + + + diff --git a/src/Sharpy.Stdlib/spy/random_module.spy b/src/Sharpy.Stdlib/spy/random_module.spy index ead90bda8..363c9a385 100644 --- a/src/Sharpy.Stdlib/spy/random_module.spy +++ b/src/Sharpy.Stdlib/spy/random_module.spy @@ -1,30 +1,43 @@ """Generate pseudo-random numbers with various distributions.""" from system import Math as SysMath, Random as SysRandom +# threading.Lock, not `from sharpy import Lock`: under the sharpy spelling `with _lock:` lowers to +# `using` (IDisposable) and never acquires the lock (#2250). +from threading import Lock _random: SysRandom = SysRandom() +# CPython's random module is thread-safe ("The underlying implementation in C is both fast and +# threadsafe"); System.Random is not, so every read and write of _random holds this one lock (#2250). +_lock: Lock = Lock() + def seed(s: int) -> None: """Initialize the random number generator with the given seed.""" - _random = SysRandom(s) + with _lock: + _random = SysRandom(s) def next_double() -> float: """Return the next random floating point number in the range [0.0, 1.0).""" - return _random.next_double() + with _lock: + return _random.next_double() def randint(a: int, b: int) -> int: """Return a random integer in the range [a, b], including both end points.""" - return _random.next(a, b + 1) + with _lock: + return _random.next(a, b + 1) def uniform(a: float, b: float) -> float: """Return a random floating point number in the range [a, b].""" - return a + (_random.next_double() * (b - a)) + with _lock: + return a + (_random.next_double() * (b - a)) def choice[T](seq: list[T]) -> T: """Return a random element from the non-empty sequence seq.""" if seq is None or len(seq) == 0: raise IndexError("Cannot choose from an empty sequence") - index: int = _random.next(len(seq)) + index: int = 0 + with _lock: + index = _random.next(len(seq)) return seq[index] def shuffle[T](x: list[T]) -> None: @@ -34,7 +47,9 @@ def shuffle[T](x: list[T]) -> None: n: int = len(x) i: int = n - 1 while i > 0: - j: int = _random.next(i + 1) + j: int = 0 + with _lock: + j = _random.next(i + 1) temp: T = x[i] x[i] = x[j] x[j] = temp @@ -44,7 +59,8 @@ def randrange(stop: int) -> int: """Return a randomly selected element from range(stop).""" if stop <= 0: raise ValueError(f"empty range for randrange() (0, {stop})") - return _random.next(stop) + with _lock: + return _random.next(stop) def randrange(start: int, stop: int, step: int = 1) -> int: """Return a randomly selected element from range(start, stop, step).""" @@ -54,7 +70,8 @@ def randrange(start: int, stop: int, step: int = 1) -> int: if step == 1: if width <= 0: raise ValueError(f"empty range for randrange() ({start}, {stop}, {step})") - return start + _random.next(width) + with _lock: + return start + _random.next(width) n: int = 0 if step > 0: n = (width + step - 1) // step @@ -62,12 +79,16 @@ def randrange(start: int, stop: int, step: int = 1) -> int: n = (width + step + 1) // step if n <= 0: raise ValueError(f"empty range for randrange() ({start}, {stop}, {step})") - return start + step * _random.next(n) + with _lock: + return start + step * _random.next(n) def gauss(mu: float, sigma: float) -> float: """Return a random floating point number with Gaussian distribution.""" - u1: float = _random.next_double() - u2: float = _random.next_double() + u1: float = 0.0 + u2: float = 0.0 + with _lock: + u1 = _random.next_double() + u2 = _random.next_double() z0: float = SysMath.sqrt(-2.0 * SysMath.log(u1)) * SysMath.cos(2.0 * 3.141592653589793 * u2) return mu + sigma * z0 @@ -79,7 +100,8 @@ def getrandbits(k: int) -> int: return 0 if k > 30: raise ValueError("number of bits must be <= 30 for int return") - return _random.next(1 << k) + with _lock: + return _random.next(1 << k) def choices[T](population: list[T], k: int = 1) -> list[T]: """Return a list of k elements chosen from population with replacement.""" @@ -89,7 +111,10 @@ def choices[T](population: list[T], k: int = 1) -> list[T]: result: list[T] = [] i: int = 0 while i < k: - result.append(population[_random.next(n)]) + index: int = 0 + with _lock: + index = _random.next(n) + result.append(population[index]) i = i + 1 return result @@ -103,7 +128,9 @@ def sample[T](population: list[T], k: int) -> list[T]: pool: list[T] = list(population) i: int = 0 while i < k: - j: int = _random.next(i, n) + j: int = 0 + with _lock: + j = _random.next(i, n) temp: T = pool[i] pool[i] = pool[j] pool[j] = temp diff --git a/src/Sharpy.Stdlib/spy/socket_module.spy b/src/Sharpy.Stdlib/spy/socket_module.spy index d359d9476..75eab24f8 100644 --- a/src/Sharpy.Stdlib/spy/socket_module.spy +++ b/src/Sharpy.Stdlib/spy/socket_module.spy @@ -3,6 +3,9 @@ from system import IDisposable, Array, Double from system.net import IPAddress, IPEndPoint, EndPoint, Dns from system.net.sockets import Socket as NetSocket, AddressFamily, SocketType, ProtocolType, SocketOptionLevel, SocketOptionName, SocketShutdown, SocketFlags, SocketException, SocketError, SelectMode +# threading.Lock, not `from sharpy import Lock`: under the sharpy spelling `with _lock:` lowers to +# `using` (IDisposable) and never acquires the lock (#2250). +from threading import Lock # ---- Address family constants (mirror System.Net.Sockets.AddressFamily) ---- AF_INET: int = AddressFamily.InterNetwork as! int @@ -39,6 +42,11 @@ SOMAXCONN: int = 128 # Default timeout applied to newly created sockets (None means blocking mode). _default_timeout: float | None = None +# CPython reads and writes the default timeout under the GIL; a float | None is a 16-byte +# struct whose write is not atomic by the CLR memory model, so every read and write of +# _default_timeout holds this one lock (#2250). +_lock: Lock = Lock() + def _check_timeout(timeout: float | None) -> None: """Refuse a timeout value python3 refuses, at the call that receives it (#2228). @@ -129,7 +137,9 @@ class socket(IDisposable): """Create a new socket with the given address family, type, and protocol.""" self._socket = NetSocket(family as! AddressFamily, sock_type as! SocketType, proto as! ProtocolType) self._timeout = None - default: float | None = _default_timeout + default: float | None = None + with _lock: + default = _default_timeout if default is not None: self.settimeout(default) @@ -429,12 +439,14 @@ class socket(IDisposable): def getdefaulttimeout() -> float | None: """Return the default timeout in seconds for new sockets, or None.""" - return _default_timeout + with _lock: + return _default_timeout def setdefaulttimeout(timeout: float | None) -> None: """Set the default timeout for new sockets. None means blocking mode.""" _check_timeout(timeout) - _default_timeout = timeout + with _lock: + _default_timeout = timeout # ---- Connection helper ---- @@ -448,7 +460,10 @@ def create_connection(address: tuple[host: str, port: int]) -> socket: The socket takes the global default timeout (getdefaulttimeout()). """ - return create_connection(address, _default_timeout) + default: float | None = None + with _lock: + default = _default_timeout + return create_connection(address, default) def create_connection(address: tuple[host: str, port: int], timeout: float | None) -> socket: """Connect to a TCP (host, port) address with the given timeout and return the connected socket. diff --git a/src/Sharpy.TestInfrastructure/Registry/MasterRegistryKeySets.cs b/src/Sharpy.TestInfrastructure/Registry/MasterRegistryKeySets.cs new file mode 100644 index 000000000..3f4b1da13 --- /dev/null +++ b/src/Sharpy.TestInfrastructure/Registry/MasterRegistryKeySets.cs @@ -0,0 +1,263 @@ +using System.Collections; +using System.Reflection; +using Sharpy.Compiler; +using Sharpy.Compiler.Discovery; +using Sharpy.Compiler.Semantic; +using Sharpy.Compiler.Semantic.Registry; +// The enclosing `Sharpy` namespace exposes Core's Pythonic collections, which shadow System's; SCG-qualify. +using SCG = System.Collections.Generic; + +namespace Sharpy.TestInfrastructure.Registry; + +/// +/// A KEY-SET snapshot of the dictionaries a 's cached master registries grow after +/// construction (P54 Design Decision 4, #2250): 's _interfaceSymbols, +/// _clrTypeCache (with the CLR type each key resolved to — the value is what the process-wide scan answered), +/// _types/_functions (with each overload set's size; the deferred interface work list is a local of the +/// constructor since Phase 2 — no registration state survives construction), the +/// and caches each master holds, and +/// 's _loadedAssemblies / _usedAssemblyPaths. +/// Every field is read by reflection on its NAME and a missing field THROWS, so a rename is red, never a silently +/// empty set. The loaded-assembly names are recorded as a set: concurrent test classes +/// grow it too, so it is evidence, never an assertion. (The process-wide name-keyed statics this once also recorded — +/// ModuleRegistry._loadedRuntimeNamespaces, ClrTypeHelper._originNameDefinitionCache / +/// _displayNameDefinitionCache — are gone: every CLR name now resolves against the registry's +/// AssemblyVisibility, #2250.) +/// +/// Symbol-property writes on the masters are MasterRegistryImmutabilityTests' reflection snapshot; +/// this is the membership half the plan says that guard lacks. Take it only between steps: the masters' +/// plain dictionaries are not safe to enumerate while an analysis writes them. +/// +public sealed class MasterRegistryKeySets +{ + /// Whether a set belongs to the compiler instance (asserted) or to the process (recorded only). + public enum Scope + { + Instance, + Process, + } + + /// One snapshotted dictionary: its path, scope and rendered entries (sorted, ordinal). + public sealed record KeySet(string Path, Scope Scope, SCG.IReadOnlyList Keys); + + /// One set's difference between two snapshots. + public sealed record Divergence(string Path, Scope Scope, SCG.IReadOnlyList Added, SCG.IReadOnlyList Removed) + { + public override string ToString() + => $"{Path} [{Scope}] +{Added.Count} -{Removed.Count}" + + (Added.Count > 0 ? $" first added: {Added[0]}" : "") + + (Removed.Count > 0 ? $" first removed: {Removed[0]}" : ""); + } + + /// The instance dictionaries of , by field name (a ROSTER — a rename throws). + public static readonly string[] BuiltinRegistryFields = + { + "_interfaceSymbols", "_clrTypeCache", "_types", "_functions", + }; + + /// The dictionaries, by field name. + public static readonly string[] DiscoveryFields = + { + "_moduleTypeSymbols", "_loadedIndices", "_moduleTypeNames", "_skeletonTypeParams", + }; + + /// The dictionaries, by field name. + public static readonly string[] BridgeFields = { "_interfaceSymbolCache", "_typeCache" }; + + /// The dictionaries, by field name. + public static readonly string[] ModuleRegistryFields = { "_loadedAssemblies", "_usedAssemblyPaths" }; + + /// Process-wide name-keyed statics: (declaring type, field name). + /// Empty since #2250 drained them (see the class summary); kept so a future process-wide static is a row here. + public static readonly (Type Owner, string Field)[] ProcessFields = System.Array.Empty<(Type Owner, string Field)>(); + + private MasterRegistryKeySets(SCG.IReadOnlyList sets) => Sets = sets; + + /// Every snapshotted set, in roster order. + public SCG.IReadOnlyList Sets { get; } + + /// The total number of entries over the instance sets (a floor for "the snapshot saw something"). + public int InstanceKeyCount => Sets.Where(s => s.Scope == Scope.Instance).Sum(s => s.Keys.Count); + + /// + /// Snapshots 's cached masters (, + /// ). Throws when no analysis context is cached yet, or when a + /// rostered field is gone. + /// + public static MasterRegistryKeySets Take(CompilerApi api) + { + var builtins = api.CachedBuiltinsForTests + ?? throw new InvalidOperationException("the CompilerApi has no cached analysis context yet: analyse or prime first"); + return Take(builtins, api.CachedModuleRegistryForTests); + } + + /// Snapshots one master and (when present) its . + private static MasterRegistryKeySets Take(BuiltinRegistry builtins, ModuleRegistry? modules) + { + var sets = new SCG.List(); + foreach (var field in BuiltinRegistryFields) + sets.Add(SetOf($"BuiltinRegistry.{field}", Scope.Instance, Read(builtins, field))); + AddDiscovery(sets, "BuiltinRegistry._discovery", Read(builtins, "_discovery")); + AddBridge(sets, "BuiltinRegistry._clrTypeMapper", Read(builtins, "_clrTypeMapper")); + if (modules is not null) + { + foreach (var field in ModuleRegistryFields) + sets.Add(SetOf($"ModuleRegistry.{field}", Scope.Instance, Read(modules, field))); + var discovery = Read(modules, "_discovery") + ?? throw new InvalidOperationException("ModuleRegistry._discovery is null"); + AddDiscovery(sets, "ModuleRegistry._discovery", discovery); + AddBridge(sets, "ModuleRegistry._discovery._typeMapper", Read(discovery, "_typeMapper")); + } + + foreach (var (owner, field) in ProcessFields) + sets.Add(SetOf($"{owner.Name}.{field}", Scope.Process, ReadStatic(owner, field))); + sets.Add(new KeySet("AppDomain.GetAssemblies", Scope.Process, + AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName().Name ?? "?").Distinct() + .OrderBy(n => n, StringComparer.Ordinal).ToArray())); + return new MasterRegistryKeySets(sets); + } + + /// The sets whose entries differ in , instance sets first, then roster order. + public SCG.IReadOnlyList DiffTo(MasterRegistryKeySets later) + { + var result = new SCG.List(); + var after = later.Sets.ToDictionary(s => s.Path, StringComparer.Ordinal); + foreach (var set in Sets.OrderBy(s => s.Scope)) + { + if (!after.TryGetValue(set.Path, out var other)) + throw new InvalidOperationException($"{set.Path} is missing from the later snapshot"); + var before = new SCG.HashSet(set.Keys, StringComparer.Ordinal); + var now = new SCG.HashSet(other.Keys, StringComparer.Ordinal); + var added = other.Keys.Where(k => !before.Contains(k)).ToArray(); + var removed = set.Keys.Where(k => !now.Contains(k)).ToArray(); + if (added.Length > 0 || removed.Length > 0) + result.Add(new Divergence(set.Path, set.Scope, added, removed)); + } + + return result; + } + + /// The instance-scope divergences only (what an assertion may rest on). + public SCG.IReadOnlyList InstanceDiffTo(MasterRegistryKeySets later) + => DiffTo(later).Where(d => d.Scope == Scope.Instance).ToArray(); + + /// One line naming the first diverging entry (instance sets first), or none. + public string FirstDivergence(MasterRegistryKeySets later) + { + var divergences = DiffTo(later); + return divergences.Count == 0 ? "none" : divergences[0].ToString(); + } + + /// Every divergence, one set per line, at most entries each. + public string Describe(MasterRegistryKeySets later, int keysPerSet = 8) + { + var divergences = DiffTo(later); + if (divergences.Count == 0) + return "no key-set change"; + var lines = new SCG.List(); + foreach (var d in divergences) + { + lines.Add($"{d.Path} [{d.Scope}]"); + lines.AddRange(d.Added.Take(keysPerSet).Select(key => $" + {key}")); + if (d.Added.Count > keysPerSet) + lines.Add($" + ... ({d.Added.Count} in all)"); + lines.AddRange(d.Removed.Take(keysPerSet).Select(key => $" - {key}")); + if (d.Removed.Count > keysPerSet) + lines.Add($" - ... ({d.Removed.Count} in all)"); + } + + return string.Join("\n", lines); + } + + private static void AddDiscovery(SCG.List sets, string path, object? discovery) + { + if (discovery is null) + throw new InvalidOperationException($"{path} is null"); + foreach (var field in DiscoveryFields) + sets.Add(SetOf($"{path}.{field}", Scope.Instance, Read(discovery, field))); + } + + private static void AddBridge(SCG.List sets, string path, object? bridge) + { + if (bridge is null) + throw new InvalidOperationException($"{path} is null"); + foreach (var field in BridgeFields) + sets.Add(SetOf($"{path}.{field}", Scope.Instance, Read(bridge, field))); + } + + private static object? Read(object owner, string field) + { + var info = owner.GetType().GetField(field, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new MissingFieldException(owner.GetType().FullName, field); + return info.GetValue(owner); + } + + private static object? ReadStatic(Type owner, string field) + { + var info = owner.GetField(field, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public) + ?? throw new MissingFieldException(owner.FullName, field); + return info.GetValue(null); + } + + private static KeySet SetOf(string path, Scope scope, object? container) + { + if (container is null) + throw new InvalidOperationException($"{path} is null"); + if (container is not IEnumerable enumerable) + throw new InvalidOperationException($"{path} is a {container.GetType().Name}, not a collection"); + + var keys = new SCG.List(); + if (IsKeyValueSequence(container.GetType())) + { + foreach (var entry in enumerable) + keys.Add(RenderEntry(entry)); + } + else if (container is ICollection collection) + { + // A list is recorded as its count: its elements are work items, not keys. + keys.Add($"count={collection.Count}"); + } + else + { + throw new InvalidOperationException($"{path} is a {container.GetType().Name}: neither a dictionary nor a counted collection"); + } + + keys.Sort(StringComparer.Ordinal); + return new KeySet(path, scope, keys); + } + + private static bool IsKeyValueSequence(Type type) + => type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(SCG.IEnumerable<>) + && i.GetGenericArguments()[0] is { IsGenericType: true } element + && element.GetGenericTypeDefinition() == typeof(SCG.KeyValuePair<,>)); + + private static string RenderEntry(object? entry) + { + if (entry is null) + return ""; + var type = entry.GetType(); + var key = type.GetProperty("Key")!.GetValue(entry); + var value = type.GetProperty("Value")!.GetValue(entry); + return $"{Render(key)} => {RenderValue(value)}"; + } + + private static string Render(object? value) => value switch + { + null => "", + Type t => t.FullName ?? t.Name, + string s => s, + _ => value.ToString() ?? "?", + }; + + /// A value is rendered where it carries the seam's evidence: which CLR type a name resolved to, an overload set's size. + private static string RenderValue(object? value) => value switch + { + null => "", + TypeSymbol { ClrType: { } clr } => $"{clr.FullName} @ {clr.Assembly.GetName().Name}", + TypeSymbol symbol => $"symbol {symbol.Name}", + string s => s, + Type t => t.FullName ?? t.Name, + ICollection collection => $"count={collection.Count}", + _ => value.GetType().Name, + }; +} diff --git a/src/Sharpy.TestInfrastructure/Registry/WarmInstanceTriggers.cs b/src/Sharpy.TestInfrastructure/Registry/WarmInstanceTriggers.cs new file mode 100644 index 000000000..7ebe7a943 --- /dev/null +++ b/src/Sharpy.TestInfrastructure/Registry/WarmInstanceTriggers.cs @@ -0,0 +1,157 @@ +namespace Sharpy.TestInfrastructure.Registry; + +/// +/// The earlier documents measured to change what ONE answers for a later +/// document (P54 #2250 cells 2–3, measured @ 43aab2658 and still live @ 486c9a88a): two Wrap-in-try APPLIED texts the +/// P47 code-action sweep analysed on its shared instance — a builtin's name (a module-level def len, a method +/// format) defined inside a try: body. After either is analysed, a later document's builtin identifier +/// takes the user definition's signature (len (object) -> int32 becomes (int32) -> int32; +/// format (object | None, str) -> str becomes (Formatter, int32) -> str, and the +/// IFormattable fixtures gain SPY0220), with no master dictionary key added. The P47 sweep stopped producing these +/// texts when Wrap in try stopped being offered over a declaration (e3c5cbdf6), which hid the cells from the sweep, not +/// the defect. Shared by WarmInstanceParityTests (Lsp.Tests) and MasterRegistryImmutabilityTests +/// (Compiler.Tests). +/// +public static class WarmInstanceTriggers +{ + /// (name, the corpus fixture whose Wrap-in-try applied text it is, the applied text). + public static readonly (string Name, string Fixture, string Text)[] AppliedTexts = + { + ("wrap-try-shadowing-len", "builtins_qualified_escapes_shadow", string.Join("\n", + "try:", + " import builtins", + "", + "", + " def len(x: int) -> int:", + " return x * 100", + "", + "", + " def main() -> None:", + " xs: list[int] = [1, 2, 3]", + " print(builtins.len(xs))", + " print(len(7))", + "except Exception as e:", + " raise", + "")), + ("wrap-try-method-format", "classes/overload_by_type", string.Join("\n", + "class Formatter:", + " try:", + " def __init__(self):", + " pass", + "", + " def format(self, value: int) -> str:", + " return \"int:\" + str(value)", + " except Exception as e:", + " raise", + "", + " def format(self, value: str) -> str:", + " return \"str:\" + value", + "", + "def main():", + " f: Formatter = Formatter()", + " print(f.format(42))", + " print(f.format(\"hello\"))", + "")), + }; + + /// + /// The TYPE-definition twins of (P54 #2250 final verification, F1/F2 @ effba7776): a + /// builtin type's name (str) defined by a class, struct, interface, union or delegate inside a block or a + /// function body, where NameResolver declares no symbol. Before the type-definition seam the checker looked the name + /// up and wrote whatever it found — the MASTER str: the mixed auto/setter property added a _size + /// backing field to it (a later document's s._size lost its SPY0203 on the same instance, measured on one + /// Sharpy.Lsp), and the dataclass text set IsDataclass on it (silently at 486c9a88a; a SPY0909 + /// PhaseViolationException ICE once the master's properties were frozen at cbabbf73a). Each is refused (SPY0202, and + /// SPY0340 for a module-level block) and writes nothing it did not declare. + /// + public static readonly (string Name, string Text)[] TypeDefinitionTexts = + { + ("class-str-in-if-in-def", string.Join("\n", + "def helper() -> None:", + " if True:", + " class str:", + " property size: int = 0", + "", + " property set size(self, v: int) -> None:", + " self._size = v", + " print(\"helper\")", + "", + "def main() -> None:", + " helper()", + "")), + ("dataclass-str-in-module-try", string.Join("\n", + "try:", + " @dataclass", + " class str:", + " x: int", + "except Exception:", + " pass", + "", + "def main() -> None:", + " print(\"ok\")", + "")), + ("struct-str-in-def", string.Join("\n", + "def helper() -> None:", + " struct str:", + " property size: int = 0", + "", + " property set size(self, v: int) -> None:", + " self._size = v", + "", + "def main() -> None:", + " helper()", + "")), + ("interface-str-in-for-in-def", string.Join("\n", + "def helper() -> None:", + " for i in range(1):", + " interface str:", + " def probe(self, value: int) -> int: ...", + "", + "def main() -> None:", + " helper()", + "")), + ("union-str-in-module-try", string.Join("\n", + "try:", + " union str:", + " case Probe(value: int)", + "except Exception:", + " pass", + "", + "def main() -> None:", + " print(\"ok\")", + "")), + ("delegate-str-in-def", string.Join("\n", + "def helper() -> None:", + " delegate str(value: int) -> int", + "", + "def main() -> None:", + " helper()", + "")), + }; + + /// + /// Every trigger text — then — for the consumers that + /// replay them by text (WarmInstanceParityTests; MasterRegistryImmutabilityTests). + /// + public static readonly (string Name, string Text)[] AllTexts = + AppliedTexts.Select(t => (t.Name, t.Text)).Concat(TypeDefinitionTexts).ToArray(); + + /// + /// Later documents whose analysis reads what a write would change: a member the + /// master str does not have (SPY0203 on a fresh instance — the verifier's c5ctl.spy), and a + /// control that names no written member. + /// + public static readonly (string Name, string Text)[] LaterDocumentProbes = + { + ("str-has-no-_size", string.Join("\n", + "def main() -> None:", + " s = \"abc\"", + " print(s._size)", + "")), + ("str-upper-control", string.Join("\n", + "def main() -> None:", + " s = \"abc\"", + " print(s.upper())", + "")), + }; +}