sharp-runtime parity boundaries and verification
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Everything was read at sharp-runtime next @ 41b918c9, which CNA 009d40f5 does not pin. Test totals and CI results are the project's own records (README, NEXT.md) with their dates; static counts were measured from the Git tree; no sharp-runtime test, workflow or build was run. The ReaderWriterLockSlim example was syntax-checked with g++ -std=c++23 -fsyntax-only against that checkout.
sharp-runtime draws an explicit line between what it reproduces from .NET and what it will never reproduce, and it keeps an unusually large apparatus to check both sides of that line. This page explains the parity target, the permanent deviations and their different reasons, the conventions that are frozen because CNA compiles against them, the platform and compiler boundaries, and how the project's tests, negative consumers, audit records and sanitizer runs support (and do not support) its claims. It is for anyone who has to decide whether a behaviour seen through CNA is a sharp-runtime defect, a deliberate boundary or an unverified area. Everything was read at sharp-runtime next @ 41b918c9, a revision CNA snapshot 009d40f5 does not pin; nothing was built or run for this page.
Maximum practical parity, and three outcomes
The project's rules state the target plainly: C++ has no GC, no IL, no runtime reflection and no delegate infrastructure, and the goal is maximum practical parity: public API, method semantics, default values, error messages and algorithms should match .NET as closely as C++ allows. That is stronger than name compatibility and narrower than reimplementing the CLR. For every operation there are exactly three acceptable outcomes:
| Outcome | What happens | What it means |
|---|---|---|
| Parity | the operation is implemented | its observable behaviour is the verification target |
| Permanent deviation | throws NotImplementedException, with a comment saying why | the mechanism depends on the CLR and is outside the runtime's design |
| Platform limitation | throws PlatformNotSupportedException with a clear message | the abstraction is valid, but this host lacks the mechanism |
A silent success value is not a fourth category: the rules forbid returning a plausible but wrong answer, and unsupported operations must still compile (including under Emscripten) so that a port fails at run time with a named reason instead of losing the API. The split lets callers and tests tell a design boundary from an environment boundary. A method whose body is a bare throw NotImplementedException() without that justification counts as a stub, not as ported.
The permanent deviations and why they differ
The rules list "known permanent deviations (not bugs, not TODO)". At 41b918c9 the list is longer than the five areas older descriptions give, and one of those five has narrowed:
| Area | Boundary at 41b918c9 | Kind of reason |
|---|---|---|
Reflection (Type, Activator, Enum.GetNames/GetValues) | "completely out of scope. Stubs are the correct end state": a stub is the intended final behaviour, not a placeholder | no meaning: a compiled C++ binary carries no runtime type metadata, and building one would be disproportionate |
Garbage collection (System::GC) | callable no-ops and fixed answers (zero statistics, false for no-GC regions, NotApplicable for full-GC notification waits, MaxGeneration 2) | no meaning: lifetime is RAII and shared_ptr |
| Delegates | three tiers; DynamicInvoke always throws NotImplementedException | C++ callables have no target-and-method equality or late binding |
| Serialization | no serializer, no BinaryFormatter, no type resolution by name, [Serializable] ignored; but since 2026-09-20 SerializationInfo is a real name-to-value store (add a name once, read it as the stored type, SerializationException otherwise) with StreamingContext and SerializationException, for CNA's exception signatures | scope: not needed for game code |
| P/Invoke and interop | out of scope | redundant: the code is already native |
| Text unit | every index, length and count is a UTF-8 byte (ticket #2015, declared 2026-08-17) | faithful adaptation: a byte index into UTF-8 is the analogue of a code-unit index into UTF-16 |
| Unicode normalization | IsNormalized returns true and Normalize returns its argument (ticket #2338); the requested form is still validated | matches .NET's invariant-globalization mode; real tables would need ICU or a second Unicode data set |
| Time-zone rules | TimeZoneInfo::GetAdjustmentRules() is empty, so HasSameRules() can only be too permissive (ticket #2185) | closing it needs a TZif reader, which is out of scope |
Encryption, certificates, TLS (Aes*, RSA*, EC*, ChaCha20Poly1305, CryptoStream, X509Certificates, SslStream) | out of scope by an explicit decision of 2026-07-07; MD5, SHA*, HMAC, PBKDF2 and random-number generation remain in scope and ported | liability: a correct implementation needs either a large external dependency such as OpenSSL or mbedTLS, or a hand-rolled security-critical one |
The reasons are genuinely different. Reflection and GC have no meaning in this runtime; serialization is a scope judgment; interop solves a problem native code does not have; the encryption boundary is a refusal to carry either the security liability of a home-made cipher or the weight of a real library for applications that do not need transport security in their runtime layer. The encryption line is narrow, not "no cryptography": digests, HMAC and PBKDF2 are ported. HMAC keys and PBKDF2 passwords are still secret material, and callers own the algorithm choice, parameter strength and security review. The delegate entry records a self-correction: the rules note that the earlier one-line version of that bullet was itself found inaccurate during an audit on 2026-07-11 and rewritten as the three tiers (the three representations).
Parity by pattern: IDisposable and ReaderWriterLockSlim
Some parity targets are reached by deliberately not copying .NET's mechanism. C++ has no construct that runs arbitrary code at the end of a block the way using does, short of an RAII wrapper per call site, so the target for IDisposable is Dispose()'s own contract (idempotent, generally non-throwing, safe to repeat), and the caller chooses how to invoke it: explicitly, from a destructor or from a scope guard.
System::Threading::ReaderWriterLockSlim shows why "looks equivalent" is not a verification result. Its header records three independent defects of an earlier version, each fixed and checked against .NET's TryEnterReadLockCore, TryEnterWriteLockCore and TryEnterUpgradeableReadLockCore: every TryEnter… overload ignored millisecondsTimeout and made one non-blocking attempt; the constructor's LockRecursionPolicy was ignored, so same-thread recursion deadlocked instead of throwing LockRecursionException under the default NoRecursion policy; and reader ownership was tracked by set membership rather than a count, so a nested EnterReadLock, EnterReadLock, ExitReadLock sequence threw SynchronizationLockException on the second exit and the reader tally could never return to zero, starving every waiting writer. The current class tracks per-thread reader, writer and upgradeable counts. Dispose() only sets a flag, so repeating it is harmless, and every entry point checks that flag first:
System::Threading::ReaderWriterLockSlim rwLock;
rwLock.EnterReadLock();
rwLock.ExitReadLock();
rwLock.Dispose(); // explicit: there is no using block
rwLock.Dispose(); // safe to repeat
try {
rwLock.EnterReadLock(); // the disposed check fires first
} catch (const System::ObjectDisposedException&) {
// named after the disposed object's class, as .NET's ObjectDisposedException(objectName)
}
Naming is a compatibility surface
sharp-runtime uses .NET's PascalCase names, but C++ has no properties, so the binding convention is getXxxProperty() and setXxxProperty(), with indexers as the systematic exception (getItem()/setItem()). At 41b918c9 the public headers contain 3,080 lines with a get…Property( call (3,226 occurrences) in 511 files. The project's rules forbid a broad header refactor because "naming conventions touch 449+ files and would break CNA": a separate repository whose conventions are frozen by its downstream consumer's compiled surface. The same rules require SharpRuntime::intcs rather than int for .NET int parameters, nested namespace syntax (namespace System::Collections::Generic {), and std::ranges instead of LINQ in the code the project writes; System/Linq.hpp nevertheless exists as a compatibility surface for ported call sites, and the project measured zero uses of it in CNA (2026-08-19).
When a naming repair is deliberate, it is atomic and leaves no alias behind. The rename of enumerators' getCurrent() to getCurrentProperty() (2514bb1f, 2026-07-13) changed 61 lines in 23 files in one commit and required downstream repair; a permanent alias would have become compatibility debris. CNA follows the same property convention on its own XNA types (properties become accessor pairs).
Platform and compiler boundaries
Platform behaviour is split more finely than "POSIX only", and every unsupported path compiles and throws instead of disappearing from the headers:
| Subsystem | Implemented on | Elsewhere |
|---|---|---|
System::Net::Sockets | Windows and POSIX | socket operations throw on Emscripten |
System::IO::RandomAccess | Windows and POSIX | throws on Emscripten |
AppDomain base directory | Windows, macOS, Linux and POSIX | Emscripten uses a relative ./ virtual-file-system fallback |
TimeZoneInfo | Windows and POSIX | Emscripten has UTC and local fallbacks and rejects system-zone lookup |
System::Diagnostics::Process, PosixSignalRegistration | POSIX | throw on Windows and Emscripten |
NetworkInterface enumeration, FileSystemWatcher | Linux (inotify for the watcher) | throw elsewhere |
The evidence behind those rows is uneven, and the project says so. Its README records a full build and test baseline on Linux with GCC, a warning-free Clang build of all 219 first-party production translation units, MinGW-w64 and Emscripten compiles of the All and Text.Json library graphs (ticket #1741) with no tests cross-built or run, and no macOS job, only fixes driven by downstream Xcode builds. A native test pass, a successful cross-compile of the libraries and execution of cross-built tests are three different results.
Decimal and the 128-bit integers
System::Decimal, System::Int128 and System::UInt128 require a compiler-provided 16-byte __int128. The root CMake file probes for it and publishes SHARP_RUNTIME_HAS_NATIVE_INT128 (always 0 or 1) on the header interface every component and consumer links, and SharpRuntimeHelper.hpp falls back to __SIZEOF_INT128__ for non-CMake consumers. x86-64 GCC and Clang, including x86-64 MinGW GCC, have it; MSVC and i686 MinGW GCC do not. This is a compiler-capability boundary, not a Windows one. When the macro is 0 the three headers refuse inclusion, Decimal.cpp is omitted, and only the members that expose 128-bit values disappear (BinaryReader::ReadDecimal, the 128-bit BitConverter and BinaryPrimitives overloads, the decimal XmlConvert overloads and a few others); everything else compiles, and a dedicated i686 MinGW regression target (SHARP_RUNTIME_BUILD_I686_REGRESSION) guards that boundary. The decision of 2026-07-11 not to hand-roll 128-bit arithmetic is recorded as permanent. For CNA this is why the built-in XNB reader count drops by one without native 128-bit support (XNB type readers).
A documented deviation still needs tests
Calling a gap permanent does not exempt its boundary from verification. GC's inert methods must stay callable and stable; Type's identity operations must work although its classification predicates are placeholders; Delegate::DynamicInvoke must fail explicitly; unsupported platform operations must throw the promised exception rather than degrade. The declared deviations of August 2026 are pinned by named tests (TextUnitContractTests.Decl2015_*, StringNormalizationTests.Decl2338_*, TimeZoneInfoTests.Decl2185_*), and the audit index gives each accepted deviation its own finding identifier (see below). The layers that do this checking are the rest of this page.
Three test locations with three jobs
The singular and plural directory names are different things:
modules/<module>/tests/holds the primary unit tests, mirroring each module's tree: 527 C++ files at41b918c9.tests/holds deliberately cross-component C++ integration tests (11 files undertests/integration/) and the i686 compile-boundary source intests/Platform/.test/holds no GoogleTest at all: eleven Python meta-tests (among themvalidate_module_boundaries_test.py,check_negative_consumer_fixtures_test.py,check_version_seam_odr_test.py,check_clang_production_build_test.py,validate_selective_component_matrix_test.pyandvalidate_audit_findings_test.py) and the CMake consumer fixtures intest/consumer/, some of which must fail to compile.
GoogleTest is a required submodule (vendor/googletest): with tests enabled and the submodule missing, the configure stops and prints the git submodule update --init --recursive remedy.
One executable per component tests the dependency boundary
sharp_runtime_add_component_tests() gives every enabled component that owns tests its own SharpRuntimeTests_<component> executable, linked only to that component, gtest_main and the component's declared TEST_DEPENDENCIES, and gtest_discover_tests prefixes each case with the component name. A test that reaches across an undeclared module edge therefore fails to link; the same body linked into one monolithic test binary would compile and hide the leak. Test-only edges stay separate from production edges, so a fixture's helper dependency cannot widen the shipped component. A selective configuration builds only the requested component's tests, and the integration executable is built only for All. SharpRuntimeTests is an aggregate build target, not a test program.
At 41b918c9, 40 of the 44 module directories own tests. Four do not: io-compression-zip, security-cryptography-random, storage and text-regular-expressions (io-isolated-storage, untested at the modularisation merge, has tests now). Integration tests partly cover three of them (compression, random-number entropy, regular-expression parity). text-regular-expressions is a 12-header INTERFACE component whose Regex is implemented inline over std::regex with the ECMAScript grammar, so .NET-only syntax is translated or stripped and match timeouts cannot interrupt a running search. Naming these components explicitly is more informative than letting a global test count imply uniform coverage. Storage is the component CNA's storage module links privately.
What the test numbers say
sharp-runtime does not summarise its state as "all passing", and neither should a reader. The README quotes a Linux baseline of 2026-08-22: 17,840 tests across 38 executables, all passed, with no skips. NEXT.md records later suites: 17,934 tests across 39 executables on 2026-08-31 after the Xml.Serialization work, and on 2026-09-19, the last entry before 41b918c9, 58 of 58 Xml.Serialization tests passing while five Xml.Linq namespace tests failed on a self-closing-element whitespace expectation (<e/> versus <e />). A static count of test macros at 41b918c9 gives 17,432 definitions, which is neither number, because parameterised and typed tests expand at registration. Older baselines, and older claims that every test passes, describe other revisions.
The arithmetic that is reconcilable is the executable count: one executable per component that owns tests plus one integration executable. What the count excludes matters as much: Python meta-tests, individual consumer configure-and-build cases, compile-only platform probes and audit documents are evidence, but they are not GoogleTest registrations and must not be added to a test total. By physical lines, test code (187,414) is about as large as production code (191,545) at 41b918c9; older measurements using code-line counters report tests about 30 % larger. Either way the ratio signals investment, not proof: untested components and recorded failures stay visible beside the large total.
Negative consumers prove absence
A positive consumer answers "does the intended dependency compile?"; a negative consumer asks "does a forbidden one fail?". Component isolation needs the second kind. test/consumer/ holds 25 positive fixtures (one per selective component configuration, such as core_base.cpp, text_json.cpp, resources.cpp, xml_serialization.cpp) and 58 negative ones: forbidden edges (forbidden_text_json_collections_blocking.cpp, forbidden_text_json_object_model.cpp, forbidden_xml_diagnostics.cpp) and API-shape constraints such as collection setters and enumerators, generic-math requirements and cryptographic key-material exclusions (security_cryptography_key_material_negative.cpp).
Each negative site is marked with #if SHARP_RUNTIME_NEGATIVE_SITE == N and a // NEGATIVE(id): <expected diagnostic> comment; the file must compile cleanly with no site selected. scripts/check_negative_consumer_fixtures.py compiles every fixture once per marked site and requires each site to be rejected for its own declared reason. The tree has 284 such markers at 41b918c9. For a negative fixture a compiler or linker failure at the expected site is success and a clean build means the boundary was weakened, so a generic CI wrapper that treats every non-zero exit as failure would reverse the verdict; that is why a Python script, itself tested, inspects the outcome and the site.
Audit is a tracked data set
audit/ holds 1,748 *.audit.md files, close to one per tracked source file, and audit/AUDIT_FINDINGS_INDEX.md assigns every evidence-backed finding a stable SR-AUD-### identifier, a severity, a status and a remediation target. Numbering is frozen at SR-AUD-364. At 41b918c9 the index reads 343 remediated, 19 accepted deviations and 2 false positives, with no open finding; the earlier split of 161 remediated and 203 confirmed belongs to the 2026-08-10 snapshot. The status words are precise: confirmed means still open (an observed deviation whose repair needs approval, evidence, a platform or a design), remediated means the contradiction was repaired and its closure gates passed, accepted-deviation records a tested boundary of the practical subset, and false-positive records a disproved premise. Freezing identifiers stops a later batch from improving the denominator by renumbering or dropping inconvenient findings.
The planning apparatus is large too: 288 Markdown documents in docs/, a root plan.md, and a tracked SQLite planning database, plan.sqlite3 (8,982,528 bytes at 41b918c9, last changed 2026-08-22), which remains in the repository although a .gitignore rule matches its name, because ignore rules do not untrack an existing file. None of it is runtime code, and it should not be counted as implementation size.
A completed task list triggered more scepticism
Earlier stabilisation work reached a fully checked-off ticket table. The response was not to declare parity but to commission fresh reviews of API consistency, silent behaviour differences, exceptions, stubs, platforms and missing high-risk tests, and they found defects that happy-path tests had missed. Each needed its own oracle, not a generic clean-up:
| Finding | Repair at 41b918c9 |
|---|---|
floating-point Convert conversions truncated with static_cast | round to nearest, ties to even, as .NET does, with OverflowException outside the range |
reading a Dictionary through non-const operator[] inserted a default value | a ValueProxy distinguishes reads (KeyNotFoundException) from writes |
ConcurrentDictionary factories ran under a non-recursive mutex, so a reentrant factory deadlocked | the factory runs without the lock; under contention it may run more than once and only one result is stored, as in .NET |
| mutable collections lacked fail-fast checks | a per-collection version discipline, later the shared MutationCounter (details) |
MemoryStream::Write let a negative offset reach an unchecked copy | argument validation before any copy (details) |
Sanitizers answer a different class of question
Sanitizer runs found failures that API comparison was unlikely to find. ThreadSanitizer exposed data races in Task::Wait() and getResultProperty() called from several threads, in Lazy's value-created flag, in TimeZoneInfo::Local() against a time-zone environment swap, and in TimeSpan's test counters. AddressSanitizer found a heap-buffer overflow in NativeMemory::AlignedRealloc and leaks in the XML DOM. The first UndefinedBehaviorSanitizer run found one diagnostic in the whole suite: a zero-length ZIP entry passed a null data() pointer through the vendored miniz writer to fwrite, whose buffer parameter is declared non-null. Reviews find other things: the lost wake-up in Channel, which woke one waiter with notify_one() where several readers could be blocked (now notify_all()), came from a code review and was then reproduced. One instructive race needed no sanitizer: TaskCompletionSource<TResult>::TrySetResult claimed completion before copying the result, so a throwing copy constructor stranded every waiter (the repair). The lesson runs both ways: sanitizers do not prove .NET parity, and a sanitizer-clean run can still contain numeric or API deviations.
Reproducibility has an external seam
The porting rules compare implementations and copy documentation from a .NET reference-source tree at an absolute path, /rv/tmp/runtime/src/libraries/, outside the repository, neither vendored nor pinned. The sharp-runtime handoff note NEXT.md (an environment re-measurement of 2026-08-19, still in the file at 41b918c9) records that tree as a .NET 11 preview snapshot, so a behaviour read from it is .NET 11's, not a timeless parity target, and it marks the older blocks that call the tree and ccache absent as stale. Source comments that cite a line of .NET's source (for example StringBuilder.cs:1024-1042) may accurately record a past comparison, but a new contributor cannot reproduce every such check from the repository alone.
The repository has a fail-fast local gate (scripts/local_ci_check.sh) and one tracked GitHub Actions workflow, .github/workflows/components.yml, triggered on every push and pull request with SHARP_RUNTIME_BUILD_JOBS set to 2. It runs ten selective component configurations, each with its consumer fixture (Core.Base, Collections.Blocking, Text.Json, Net.Http.Headers, Net.WebSockets, IO.Compression, IO.Compression.Zip, IO.IsolatedStorage, Security.Cryptography.Random, Xml.Linq), a full compatibility job that runs the local gate (a Clang production build with -Werror, then the full GCC build and tests, after enabling unprivileged ICMP for the ping tests), and a Doxygen-warning job. All three run on Ubuntu. The workflow's presence proves that a hosted trigger and named Linux jobs exist; it does not prove that every historical revision passed, and it supplies no Windows, macOS or Emscripten execution.
What this means for CNA
CNA forces the sibling's tests off in its own build, so none of the evidence on this page is produced by CNA's CI; it belongs to the sharp-runtime revision it was recorded for. A CNA report that touches a System:: path should name that revision, keep sharp-runtime's recorded failures and untested components in view, and classify an unexpected behaviour with the three outcomes above before calling it a CNA defect. CNA's own boundaries with the sibling (which components it links, the Windows Xml.Serialization exclusion, the global macros its public headers inherit) are on components and consumption; how CNA itself distinguishes representation from behaviour is on CNA and XNA 4.0.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Architecture: required sibling repositories
- Tests and validation
- CNA test architecture