I need to update the C API

CNA snapshot 3b43bd86  ·  Development › Maintainer Handbook  ·  source links pinned to 3b43bd86

✓

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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Read at 3b43bd86 from modules/c-api (headers, specs, generated routes, sources, tests), tools/c-api, docs/c-api, the c-api-* workflows, CHANGELOG.md and CNA's plan records; nothing was configured, built or run, no workflow builds the C library, and the recorded C API builds refer to other commits.

Use this recipe when a change touches modules/c-api, a public C++ header the C layer adapts, or anything the C ABI records about itself: its version, layout baseline, coverage inventory or release gate. The C layer is a second ownership model over the canonical C++ surface and has more mechanical gates than any other module; most read files rather than a built library, so they register and run in an ordinary tests build. Since the 0.1.0 release its route boundaries are generated: a route is a header declaration plus one spec line, and only the logic of a complex route is written by hand. Everything here was read from the TARGET tree at 3b43bd86; nothing was configured, built or run for this page.

⚠

What is and is not established. No workflow at TARGET builds libcna_c_api; the six c-api-*.yml workflows check headers, specs, generated files, JSON and Doxygen output only, and every shipped preset sets CNA_BUILD_C_API=OFF. CNA's own records report C API builds and ctest -R '^CApi' runs on other commits: the plan record plans/plan_capi_smoke_stability.md on an earlier next commit (111 tests, 108 passing, the three failures being generator gates), the earlier commit that set ABI 0.44.0 (ed885a8ae, an ancestor of this snapshot: ^CApi 119/119, C API gates 9/9), CNA’s Apple campaign on a physical Mac mini M4 (every C API test in two renderer trees, the installed-package consumer shared and static, the export and baseline gates read from the Mach-O library) and the 0.1.0 release run, whose complete suite carried CApi_InstalledConsumer as a known failure (an installed consumer could not find wgpu_native; [Unreleased] records the package fix and the test passing for both consumers). Those are local measurements of other commits, not CI and not evidence produced for this page, and all of them predate the generated route boundaries. A C++ change can therefore break the C layer without any automatic signal, and the C API's history contains exactly that.

Find the owner

The C layer lives in one module and is meant to change in the same commit as the C++ it adapts (docs/c-api/README.md). Its pieces and their checks:

PieceWhereChecked by
60 public headers (C99 floor), aggregated by cna.hinclude/CNA/CCApi_HeaderAudit (no std::, class, template, namespace, throw; only stdint.h, stddef.h, stdbool.h and CNA/C/*.h may be included), CApiCompatibilityMatrix, CApiHeaderCompatibility
Route boundaries: one spec line per route in 56 family specs, generated into 56 .inc files that are never editedspec, src/generated, written by generate_routes.py; the procedure is in GENERATED_ROUTES.mdCApiGeneratedRoutesCurrent (--check), CApiRouteGeneratorTests, the generated CApi_GeneratedRoutesSmoke and the c-api-generated-routes workflow
Implementation: 59 translation units (each family ends with #include "generated/<Family>.inc" and holds the hand-written helpers and lookups) plus private headers: CnaCApiDetail.hpp (barrier, refusals, registry), CnaCApiOutput.hpp (outputs, size-then-copy), CnaCApiMarshal.hpp (C↔C++ conversions) and the family *Detail.hppsrc, listed explicitly in modules/c-api/CMakeLists.txt (no glob)The C API build itself (-Wall -Wextra -Werror) and the pure-C tests
Route teststests/pure_c (94 *Smoke.c programs, one of them generated, plus AbiHeaderC.c, RuntimeComponentDeviceBorrow.c and ServiceClient.c), tests/cpp (registry, exception barrier, owner-thread runtime and UTF-8 oracle, plus the C++ header compile check), tests/fuzzOne add_test per program, under CNA_BUILD_TESTS
Recorded layout and exportsabi_baseline.json: 196 struct layouts, 296 scalar types, 1,478 integer constants, 3,212 exportsCApiAbiHeaderBaseline (headers only), CApiAbiBaseline (built library), the c-api-abi-baseline workflow
Coverage inventory and release gatecoverage_mappings.json (628 rules), the generated summary, release_gate.json, RELEASE_GATE.mdCApiCoverageMatrix, CApiLimitations, CApiReleaseGate and their workflows
Contract prosedocs/c-apiCApiDocExportCounts holds the export counts in four of the documents

First decide whether the change belongs in the ABI at all. The inventory records 453 public C++ symbols as having no C form (templates over types C cannot name, protected hooks, the platform substrate, renderer implementations behind IGraphicsRenderer). Touching such a symbol needs no route but must leave the inventory consistent. The scope rules are in generate_coverage_inventory.py: MODULE_SCOPE classifies every module, so a new module stops the gate instead of inheriting a default.

Read first

  1. abi.h: CNA_ABI_VERSION_MAJOR/MINOR/PATCH (0, 50, 0), CNA_ABI_VERSION_ENCODE, CNA_Result codes 0–14, CNA_Handle.
  2. docs/c-api/ABI_VERSIONING.md: the 0.x rule, the four evolution paths, the recorded-baseline section.
  3. OWNERSHIP.md, HANDLES.md and CALLBACKS_AND_THREADING.md, then C API internals for how the code implements them; this page does not repeat that trace.
  4. CnaCApiDetail.hpp (CallWithExceptionBarrier, HandleRegistry, ObjectKind, the named refusals), then the family’s spec in modules/c-api/spec, the CnaCApi<Family>.cpp that owns a neighbouring route, and its pure-C smoke test.
  5. GENERATED_ROUTES.md: the route kinds (value, get, call, size, copy, release, result), which conversions the generator applies, and when a route is a hand-written helper.

Contracts a route must keep

  • Handles are 64-bit: a 1-based slot in the low 32 bits, a generation in the high 32. HandleRegistry::Get refuses a stale generation or wrong ObjectKind (CNA_RESULT_INVALID_HANDLE) and a foreign creation thread (CNA_RESULT_THREAD); Release and the user-tag accessors check the thread too; only GetFromAnyThread skips it, for the sound-effect play lookup (0.43.0). Once a slot's last 32-bit generation is released the slot is retired, not wrapped. There is one process-wide registry. The kind is the only type guard, so resolving a handle under the wrong C++ type is undefined behaviour, not an error.
  • The exception barrier is positional. Catch arms are ordered most-derived first, so a new or re-parented native exception can land in a different arm; CApi_BoundaryDetail pins representative mappings only.
  • Threading is part of the contract. A refusal that Get raises only because another thread created the handle is noted, and with cna_game_set_foreign_thread_calls_ext on (0.39.0, off by default) CallWithExceptionBarrier re-runs the whole route body on the game thread while the caller blocks; the game thread serves the queue at the start of each update and draw and in cna_game_run_foreign_thread_calls_ext (0.41.0). A new route that resolves its handles through the registry gets this for free; a route that must itself run on the game thread has to refuse with a THREAD that is not noted, as driving or destroying the game does. Allowing a route on any thread, or in a new phase (before the run, in an event handler), is a rule change that takes a minor.
  • A refused creation leaves the output invalid: the generated half of a route clears every handle output to CNA_INVALID_HANDLE and refuses a null output before a helper runs, and checks faults in one order (a null output, then a non-canonical CNA_Bool, then a refused argument, then handles in parameter order).
  • Structures are versioned prefixes (struct_size >= sizeof, struct_version == 1); fields are appended, never reordered. Every CNA_Bool route refuses bytes other than 0 and 1, held by the generated CApi_BoolContractSmoke.
  • Ownership is written into the header. OWNERSHIP.md names six categories (owned, borrowed, retained, transferred, callback context, caller buffer) and calls a declaration incomplete until its Doxygen names one. No test enforcing this was found; it is a review obligation.
  • Owned children block their parent: cna_game_destroy answers CNA_RESULT_INVALID_STATE while an owned graphics resource, content manager, audio resource or game component lives. Event registrations are the deliberate exception. A new owned child type must join the relevant counter, not only the registry.
  • Two language levels. Headers are held to C99 (23 declared matrix cells across 7 toolchains); CNA's C targets compile as C17 with extensions off and warnings as errors.

ABI version discipline

The ABI version is independent of the product version string and of Sharp Runtime's; docs/releasing.md says it “moves when the ABI changes, independently of a product release”. It is defined once, by three macros in abi.h, encoded major<<16 | minor<<8 | patch (0.50.0, this snapshot, is 0x00003200). Everything else derives from or is held against them:

Consumer of the versionHow it stays in step
CMake package CNAConfigVersion.cmakeThe module CMake file regex-reads the macros and writes the version file with SameMajorVersion; the package version is the ABI version. The C++ SDK package (CNA_INSTALL_CXX, InstallCxx.cmake) reads the same macros, so both installed CNA packages carry the C ABI version.
abi_baseline.jsongenerate_abi_baseline.py measures the macros from the headers and, with --library, what cna_get_abi_version() reports.
AbiHeaderC.cDeliberately carries no version literal: it checks only the encoding. A literal had gone two bumps stale (0.27.0 against 0.29.0) unnoticed and broke the build when the C API was next built.
ProseThe identity paragraph and history in ABI_VERSIONING.md; export counts are held by CApiDocExportCounts.
ELF version node CNA_C_API_0.1Intentionally not the ABI version and never bumped with it; it would change only with a major break (CnaCApiExports.map).

When the version moves

While the ABI is 0.x, an incompatible change needs a minor increment, release notes and a regenerated baseline; from 1.x, only additive changes within a major, and any removal or meaning change needs a new major. CNA also increments the minor for additive generations (0.36.0, 0.38.0, 0.39.0 and 0.41.0 each added one route; 0.50.0 added seven routes and some constants) and for a rule that widens with every signature unchanged, “because a consumer needs a number to require”. ABI 1.0 is a separate decision the release gate says it does not make.

ChangeClass and versionRecorded at TARGET
New route, struct, appended field, constant or handle kindAdditive; minor by project practice0.36.0 (cna_packet_reader_copy_data_ext), 0.37.0 (four exception type and parameter-name queries), 0.38.0 (cna_game_run_frame_ext), 0.39.0 and 0.41.0 (the two foreign-thread call routes), 0.46.0 (the native-window-system constant CNA_NATIVE_WINDOW_SYSTEM_UIKIT), 0.50.0 (skinned-model part colour, CNB model cameras and five renderer features, and the restored CNA_DisplayColorSpace typedef, a uint32_t that changes no binary signature)
Route, struct or constant removed or renumberedIncompatible; minor while 0.x, with notes and a regenerated baseline0.30.0 deleted a whole header, engine_layer.h, and with it 842 net exports and 23 layouts; the gamer-services line removed eleven avatar real-rendering routes and CNA_AvatarAppearanceEXT (its 0.33.0) and the routes cna_guide_set_is_visible and cna_guide_set_is_trial_mode (its 0.34.0), and 0.35.0 merged the two lines that had both used 0.33.0 under an unused number; 0.47.0 removed the C surface of the retired modern GPU layer (two capability identities, four renderer features, thirteen limits, three format-usage bits, two structures and five routes, every surviving number kept and the removed ones reserved); 0.49.0 renamed the renderer classification surface from “graphics backend” to “graphics renderer” (eight routes and their typedefs and constants, no alias kept, so the export count did not change)
Meaning of an existing route, ownership rule, error rule or callback rule changesIncompatible; minor while 0.x0.3.0 (every CNA_Bool route refuses non-canonical bytes); 0.9.0 (seven documented contract changes, four of which had landed unversioned under 0.8.0); 0.32.0 (Guide and the gamer-services dispatcher answer by configured service availability, and a duplicate initialization answers INVALID_STATE)
A call that was refused is now accepted (a thread, phase or handle rule widens); every export, layout and constant unchangedRule-only; minor by project practice. It passes every link and baseline check, so only the release notes and the header text show it0.40.0 (the twelve cna_graphics_adapter_* routes accept the game handle), 0.42.0 (the device is lent before the run begins), 0.43.0 (sound-effect play on any thread), 0.44.0 (Activated and Deactivated handlers may borrow the device)
A renderer identity constant leaves the ABIIncompatible plus a permanent reservation of its valueRecorded step by step in ABI_VERSIONING.md, most recently 0.48.0, which retired four identities (values 1, 8, 14 and 42) and left ten; CNA_GRAPHICS_RENDERER_MAXIMUM stays 43 (FNA3D), the next new identity takes 52, not MAXIMUM + 1, and scripts/check_renderer_identities.py fails on reuse
C++ behaviour changes, every C signature unchangedDepends on whether a documented C contract movesPractice is mixed: 0.9.0 versioned such changes, while the plan record of thirteen stale smoke-test expectations (defaults, refusal codes, text formats) after the library stopped compiling corrected two header comments and the tests with no bump. Decide explicitly and say in review whether the old promise was part of the header

How a version is bumped

Read from the tools and workflows; not executed. No script bumps the version: each step is a manual edit that a gate then holds.

  1. Edit the macro in abi.h; repeat the literal nowhere that is not gated.
  2. With the library built: python3 tools/c-api/generate_abi_baseline.py --write --library <build>/modules/c-api/libcna_c_api.so, and review the abi_baseline.json diff. Additions are permitted; a moved field, changed constant or vanished export is named an ABI break.
  3. Write the entry at the top of ABI_VERSIONING.md and keep the export counts in the four gated documents equal to the baseline (check_doc_export_counts.py --check).
  4. python3 tools/c-api/check_release_gate.py --write regenerates RELEASE_GATE.md, whose “Release” line names the ABI version.
  5. Add a CHANGELOG.md entry under [Unreleased] (the entries that moved the ABI to 0.29.0, 0.31.0 and 0.33.0 name what was removed, the deltas and the deciding plan row). Practice is uneven: ABI_VERSIONING.md records every step from 0.30.0 to 0.50.0, but CHANGELOG.md names only some of them ([Unreleased] names 0.48.0 and 0.49.0, nothing names 0.47.0 or 0.50.0), so read the history in ABI_VERSIONING.md.
  6. Leave the installed-consumer example's find_package(CNA 0.1 CONFIG) alone: it requests the version it was written against on purpose. Bindings are updated separately (below).

The coverage inventory and the release gate

generate_coverage_inventory.py parses modules/*/include/{Microsoft,CNA}/**/*.hpp with Doxygen (Internal and Detail path segments excluded) and maps every declaration through the rules. Read the numbers from the generated summary: at TARGET, 466 in-scope headers and 8,134 symbols, of which 7,033 implemented, 15 partial, 633 planned and 453 not applicable, with 406 headers explicitly excluded (content pipeline, design, diagnostics, inspector, phone, platform, renderers, internal paths and the build-time content import and pipeline subtrees, each with a recorded reason). Planned rows by module: graphics 252, content 203, graphics-ext 36, input 32, math 32, gamer-services 28, runtime 19, net 14, audio 11, devices 2, media 2, storage 2.

The release gate reads Not ready: nine criteria are recorded as met, and one, “No public C++ symbol is unaccounted for”, is recorded as not met because of those 633 rows. MODULE_SCOPE at TARGET classifies every module that exists, design, diagnostics and inspector included, so the scope check the inventory runs first has an answer for each; the gate was not re-run for this page. The gate is built to fail in both directions: check_release_gate.py fails when a criterion recorded as met stops being met and when one recorded as blocked has quietly become met, so closing the last planned row also forces a change to release_gate.json. It governs publishing an experimental release only.

SituationWhat failsWhat to do
A new public C++ symbol in a module the C ABI linksCApiCoverageMatrix, then the release gateBind it and extend or add a rule (status implemented, a task, a tests text), or record an owner-approved not-applicable/partial. Nothing is reclassified to turn a gate green.
Editing a header a rule matchesThe rule's approved_symbols disagree with what it matchesRules are pinned to stable CPP-… IDs; widening one needs --approve-rule-symbols --rule ID --grow-approved-symbols after a human checks the routes cover the new symbols.
A new modulevalidate_module_scopeClassify it in MODULE_SCOPE with an owner decision.
Prose about limits is staleCApiLimitationsEdit limitations.json or the mapping and run generate_limitations.py --write; never hand-edit generated files.

The inventory tools need Doxygen and the sibling sharp-runtime checkout (the workflows install Doxygen and run scripts/ci/clone_siblings.sh); the ABI-header, route-coverage, bool-contract and compatibility checks need only Python and a C compiler. Two documentation gaps: the per-task open-row counts in plans/plan_binding.md (320 for CBIND-127) are not kept equal to the generated 633, and the gate’s release note still records 654 content-pipeline declarations under an open task although the summary scopes that module out. Trust the generated summary.

Adding a route (procedure, not executed)

The shortest path that satisfies every gate for a new route in an existing family, derived from GENERATED_ROUTES.md, existing routes and tests. A new handle kind grows steps 3–4; a new module grows the scope row above.

  1. Write the contract first. Owned, borrowed, retained, transferred or copied; which thread; which result codes; whether a child count is involved. Put it in the Doxygen of a declaration that is C99, CNA_C_API-marked, cna_-prefixed and fixed-width only (no long, size_t, bool or bit-fields).
  2. Classify the ABI change with the table above.
  3. Generate it. A mechanically regular route (a property read or write, a command, a value computation, a text size/copy pair, a destroy with nothing to undo) is one line in modules/c-api/spec/<Family>.toml. A route with real semantics (a creation that publishes and counts, an event, a bulk transfer) is a result line in the family’s [helpers] group that calls a hand-written helper named after the route in CamelCase, in CnaCApi<Family>.cpp; the generated half supplies the exception barrier, the null-output refusal, the cleared handle outputs and the CNA_Bool check, so the helper does not repeat them. Run python3 tools/c-api/generate_routes.py and commit the spec, the helper and the regenerated .inc together. Only the cna_error_* queries and cna_get_abi_version are written whole by hand; a new fully manual entry point needs a written reason. A new source file must be added to the explicit add_library(cna_c_api ...) list.
  4. If it owns a resource, add an ObjectKind, a parent token and, for a game child, the matching owned counter so cna_game_destroy refuses while it lives. The ELF map exports cna_* by pattern, as does the macOS exported-symbols list (_cna_*, CnaCApiExports.darwin.txt), and the wasm export list is generated from the headers (generate_wasm_exports.py); neither is edited.
  5. Write the pure-C test with <CNA/C/cna.h> and CnaTestReport.h (CNA_TEST_FAIL(n) names the failing stage). Cover success, a refused argument (output stays CNA_INVALID_HANDLE), a stale handle, the wrong thread, and child-before-parent destruction. Register it beside its neighbours: add_executable, C_STANDARD 17, C_EXTENSIONS OFF, link cna_c_api, cna_c_api_enable_strict_warnings, add_test(NAME CApi_…), and SDL_VIDEODRIVER=dummy where a window opens.
  6. Route-test coverage. CApiRouteTestCoverage has a budget of 0 uncovered routes and counts a route as covered when any file under tests/ or examples/ names it, the generated GeneratedRoutesSmoke.c included, so a generated route is “covered” before anyone tests what it computes. If the route takes a CNA_Bool, run generate_bool_contract_test.py --write or CApiBoolContractCurrent fails.
  7. Record layout and exports (generate_abi_baseline.py --write --library ..., additions only expected), then check_declared_exports.py --library ..., which compares declared and exported names both ways.
  8. Update the inventory: mapping rule, then generate_coverage_inventory.py --write and generate_limitations.py --write; a route that closes a planned row should lower the count.
  9. Bump the version and update ABI_VERSIONING.md, CHANGELOG.md and the family document (AUDIO.md, GRAPHICS_DEVICE.md, …).
  10. Run the gates below, and the installed-consumer check if headers, exports or packaging changed.

Tests and gates

GateNeedsCatches
CApiAbiHeaderBaseline, CApiAbiBaselineTests, CApiCompatibilityMatrix, CApiHeaderCompatibility, CApiBoolContractCurrent, CApiRouteTestCoverage, CApiDocExportCounts, CApiGeneratedRoutesCurrent, CApiRouteGeneratorTestsOrdinary tests build with Python 3 (3.11 or later for the route generator); no C libraryMoved fields or constants, headers not self-contained at C99, a stale generated bool test or route file, a generator regression, routes with no caller, stale export counts
CApiCoverageMatrix, CApiCoverageScopeModel, CApiLimitations, CApiReleaseGatePython 3; the inventory ones also Doxygen and sharp-runtimeAn unmapped public symbol, a stale generated summary, a criterion that disagrees with its measurement
CApiAbiBaseline, CApiDeclaredExports, CApi_ExportsThe built library, ELF or Mach-O (registered on macOS too; each tool decides by the file’s magic)A vanished or unexpected export, a declared route the library does not export, a wrong reported version
The CApi_*Smoke programs (the generated CApi_GeneratedRoutesSmoke among them), CApi_TeardownLifetime_* (seven modes), CApi_StressSmoke, CApi_HandleRegistry, CApi_BoundaryDetail, CApi_OwnerThreadRuntimeCNA_BUILD_C_API=ON with CNA_ENABLE_NET=ONRoute behaviour, refusals, handle recycling, process-exit teardown
CApi_InstalledConsumerC API build, Linux/ELF and macOS, serial, 900 s timeoutAn install unusable from outside the tree, shared and static, without an environment variable
CApi_WasmLinkContract, CApi_WasmModuleSmoke, CApi_WasmBrowserProbeEmscripten with Node (Playwright for the probe)A route missing from the browser module

CNA_BUILD_C_API=ON requires CNA_ENABLE_NET=ON (a configure-time refusal otherwise) and is OFF in every shipped preset. Pair a lifetime change with a sanitizer tree: the process-exit crash in this module was found and proved with AddressSanitizer, and CApiAbiBaseline forwards CNA_SANITIZE so a sanitized tree can still run it. See debug shutdown and lifetime.

What a C++ change costs downstream

C++ changeInside the tree
Rename, re-type or remove a wrapped public symbolThe C layer stops compiling. The effect collections' operator[](int) returned T* while CnaCApiEffects.cpp still took its address, unnoticed until the library was built again (CHANGELOG, “-DCNA_BUILD_C_API=ON builds again”).
Add or remove a renderer identityTwo static_asserts in CnaCApiCoreExt.cpp compare the 10-row identity table with the C++ count and CNA_GRAPHICS_RENDERER_MAXIMUM with the highest published value. The build failure at the first tag was exactly this assertion; removing a constant is also an incompatible ABI step.
A virtual stops being pure or a default changesAn adapter override written for the old contract may now be wrong: Effect::Clone() stopped being pure virtual and the C adapter's override silently dropped a compiled effect's runtime, so the override was deleted.
A generic template the wrapper reached changes semanticsA route regressed while both test suites were individually right: the raw vertex upload route, repaired by two new public forwarders on DynamicVertexBuffer.
A new or re-parented native exceptionMay move failures between barrier arms, giving a different CNA_Result.
A profile or limit tightens (Reach enforcement, depth-stencil refusals)Smoke tests written against the old default fail: CNA's plan classified thirteen stale expectations after the library stopped compiling.

The maintained external binding, CNA.NET (cna-dotnet), is a separate repository on its own cadence. It admits exactly one reviewed ABI point, 0.47.0 (admitted at its commit f1f88e1, 13 October 2026, after 0.46.0 at 8219a6c), so it refuses the 0.50.0 library of this snapshot, and every new minor, additive, incompatible or rule-only, needs a reviewed point there before CNA.NET accepts the library. The eight archived bindings (Common Lisp, Go, Java, Python, Ruby, Rust, Swift and TypeScript, kept in CNA Cemetery) are frozen at their final revisions and receive no C API change; they stopped at 0.21–0.23 (Common Lisp) and 0.35.0 (the other seven), and none is qualified against this ABI (per-binding table on the bindings boundary). For a C API change this means: (a) a 0.x minor is not backwards compatible, yet find_package accepts every later 0.x; ABI_VERSIONING.md and CONSUMING.md therefore ask a 0.x consumer to refuse at startup, through cna_get_abi_version(), any minor it was not written against (hello_cna.c shows the check), and a binding that admits a range trusts a rule, not a guarantee; (b) an additive route is invisible to a binding until its own manifest adds it (the C# binding compiles a matrix, the archived Python binding's loader refuses a missing export by name); (c) a rule-only minor changes no export, so only the binding's own refusals, threading and tests reveal it; (d) CNA cannot run the bindings' suites, so the evidence a maintainer can hand over is the regenerated baseline, the header diff and the release notes. The migration shapes are on the C# page (and, as records, on the archived Java and Python pages), and the propagation checklist is in C API and bindings architecture.

Traps

  • Green ordinary build, broken C layer. Build cna_c_api in a CNA_BUILD_C_API=ON tree before claiming a wrapped-header change is unaffected, or say plainly that you did not.
  • A defined result code nothing returns. CNA_RESULT_SHUTTING_DOWN (13) is declared, mapped to an error category and documented in HANDLES.md (“runtime is closing or closed”), yet searching modules/c-api/src finds no route returning it; a stale handle answers INVALID_HANDLE. Do not document or test behaviour the implementation lacks.
  • Static destruction. The registry is a function-local static in CnaCApiRuntime.cpp that owns games; an atexit handler registered once a game exists destroys what is left before the libraries a device loaded are finalized. CNA made the platform bookkeeping it depends on immortal because reverse-order destruction otherwise read freed memory at exit; a new process-lifetime static that a Game destructor touches must follow that idiom.
  • A finalizer is not a destroyer. A garbage-collected wrapper releasing children on another thread gets CNA_RESULT_THREAD and loses the handle.
  • Counting a mention as a test. CApiRouteTestCoverage passes on a name appearing in any test source, the generated contract test included; that test proves the boundary rules, not what the route computes, so assert behaviour in the family’s own smoke test.
  • Editing a generated file. src/generated/*.inc and GeneratedRoutesSmoke.c are rewritten by the generator and checked by CApiGeneratedRoutesCurrent; change the spec or the helper instead.
  • Caller-created devices. cna_graphics_device_destroy disposes without a child-count check in its route body, and that is the documented contract: graphics_device.h says resources on a caller-created device belong to it and are released with it (GraphicsDevice::Dispose disposes its resource list), so destroying the device first is permitted. The pure-C tests still destroy resources first, and what a child handle answers after its device is gone is not pinned by a test. Treat that part as an open rule.

Review checklist

  • Header: C99 only, ownership category, thread rule and result codes written down.
  • Implementation: a spec line where one suffices, otherwise a result helper; generated files regenerated and committed with their sources; kind and thread through the registry, child counter, callback re-entrancy rules kept.
  • ABI: change class named; abi.h, baseline, ABI_VERSIONING.md, CHANGELOG.md, release-gate record and prose export counts agree.
  • Tests: a pure-C test that fails when the route is wrong; the destroy-order and wrong-thread cases; which gates ran, which need a built library, which were not run.
  • Inventory: no unreviewed planned row, no rule widened without the approval flag, limitations regenerated.
  • Bindings: the ABI point CNA.NET admits and whether the change needs a new one, the fact that the archived bindings are not updated, and any removal, renumbering or rule-only widening called out.

Related: Ownership and lifetime master map, Thread and callback map, Blast radius and readiness, Make a release and Change public XNA behaviour.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture