sharp-runtime parity boundaries and verification

CNA snapshot 009d40f5  ·  Deep Dives › sharp-runtime  ·  source links pinned to 009d40f5

✓

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:

OutcomeWhat happensWhat it means
Paritythe operation is implementedits observable behaviour is the verification target
Permanent deviationthrows NotImplementedException, with a comment saying whythe mechanism depends on the CLR and is outside the runtime's design
Platform limitationthrows PlatformNotSupportedException with a clear messagethe 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:

AreaBoundary at 41b918c9Kind 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 placeholderno 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
Delegatesthree tiers; DynamicInvoke always throws NotImplementedExceptionC++ callables have no target-and-method equality or late binding
Serializationno 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 signaturesscope: not needed for game code
P/Invoke and interopout of scoperedundant: the code is already native
Text unitevery 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 normalizationIsNormalized returns true and Normalize returns its argument (ticket #2338); the requested form is still validatedmatches .NET's invariant-globalization mode; real tables would need ICU or a second Unicode data set
Time-zone rulesTimeZoneInfo::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 portedliability: 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:

SubsystemImplemented onElsewhere
System::Net::SocketsWindows and POSIXsocket operations throw on Emscripten
System::IO::RandomAccessWindows and POSIXthrows on Emscripten
AppDomain base directoryWindows, macOS, Linux and POSIXEmscripten uses a relative ./ virtual-file-system fallback
TimeZoneInfoWindows and POSIXEmscripten has UTC and local fallbacks and rejects system-zone lookup
System::Diagnostics::Process, PosixSignalRegistrationPOSIXthrow on Windows and Emscripten
NetworkInterface enumeration, FileSystemWatcherLinux (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 at 41b918c9.
  • tests/ holds deliberately cross-component C++ integration tests (11 files under tests/integration/) and the i686 compile-boundary source in tests/Platform/.
  • test/ holds no GoogleTest at all: eleven Python meta-tests (among them validate_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.py and validate_audit_findings_test.py) and the CMake consumer fixtures in test/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:

FindingRepair at 41b918c9
floating-point Convert conversions truncated with static_castround to nearest, ties to even, as .NET does, with OverflowException outside the range
reading a Dictionary through non-const operator[] inserted a default valuea ValueProxy distinguishes reads (KeyNotFoundException) from writes
ConcurrentDictionary factories ran under a non-recursive mutex, so a reentrant factory deadlockedthe 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 checksa per-collection version discipline, later the shared MutationCounter (details)
MemoryStream::Write let a negative offset reach an unchecked copyargument 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.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
CNA test architecture