I need to update the C API

CNA snapshot 009d40f5  ·  Development › Maintainer Handbook  ·  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. Read at 009d40f5 from modules/c-api, tools/c-api, docs/c-api, the c-api-* workflows and CNA's plan records; nothing was configured, built or run, no workflow builds the C library, and the plan records of a C API build refer to an earlier commit.

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. Everything here was read from the TARGET tree at 009d40f5; nothing was configured, built or run for this page.

⚠

What is and is not established. No workflow at TARGET builds libcna_c_api; the five c-api-*.yml workflows check headers, JSON and Doxygen output only. CNA's own plan record (plans/plan_capi_smoke_stability.md) reports a C API build and a ctest -R '^CApi' run on an earlier next commit (111 tests, 108 passing, the three failures being generator gates); that is CNA's measurement of another commit, not evidence for 009d40f5. 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
61 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
Implementation: 59 translation units plus private *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 (84 *Smoke.c programs plus AbiHeaderC.c), tests/cpp (registry, exception barrier, UTF-8 and LUT oracles), tests/fuzzOne add_test per program, under CNA_BUILD_TESTS
Recorded layout and exportsabi_baseline.json: 221 struct layouts, 350 scalar types, 1,558 integer constants, 4,055 exportsCApiAbiHeaderBaseline (headers only), CApiAbiBaseline (built library), the c-api-abi-baseline workflow
Coverage inventory and release gatecoverage_mappings.json (693 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 509 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, 29, 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, validators), then the family file that owns a neighbouring route and its pure-C smoke test.

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. 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.
  • A refused creation leaves the output invalid: clear every out_ handle first, validate second.
  • 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.29.0 is 0x00001D00). 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.
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.23.0 added one route), “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.23.0 (cna_decal_pass_is_supported), 0.26.0 (one CNA_RENDERER_FEATURE_* identity)
Route, struct or constant removed or renumberedIncompatible; minor while 0.x, with notes and a regenerated baseline0.29.0 removed cna_sprite_batch_draw_mesh_ext and CNA_SpriteMeshEXT (4,056 → 4,055 exports, 222 → 221 layouts); 0.28.0 removed the constants of the curated-out renderer identities and moved CNA_GRAPHICS_RENDERER_MAXIMUM 51 → 46
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)
Renderer identity retiredIncompatible plus a permanent reservationThe next new identity takes 52, not MAXIMUM + 1; 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 0.29.0 entry names the removed route, the export and layout deltas and the deciding plan row).
  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, 556 in-scope headers and 9,355 symbols, of which 8,363 implemented, 15 partial, 468 planned and 509 not applicable, with 425 headers deliberately out of scope (content pipeline, phone, platform, renderers and internal paths, each with a recorded owner decision). Planned rows by module: content 188, graphics 116, graphics-ext 133, input 10, math 10, runtime 11.

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 468 rows. Running the gate against this snapshot measures two unmet, not one: the coverage inventory stops at its scope check (“The runtime C API scope model does not describe this tree”), which also fails the limitations criterion recorded as met, so check_release_gate.py --check fails. That is the gate working: 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 468, and release_gate.json's note still speaks of 654 content-pipeline declarations although the summary now scopes that module out. Trust the generated summary.

Adding a route (procedure, not executed)

The shortest path that satisfies every gate for a new fallible route in an existing family, derived from 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. Implement it in the owning CnaCApi*.cpp family: clear outputs, CallWithExceptionBarrier, kind and thread through the registry, shared validators. 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 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. 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, CApiCompatibilityMatrix, CApiHeaderCompatibility, CApiBoolContractCurrent, CApiRouteTestCoverage, CApiDocExportCountsOrdinary tests build with Python 3; no C libraryMoved fields or constants, headers not self-contained at C99, a stale generated bool test, 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 onlyA vanished or unexpected export, a declared route the library does not export, a wrong reported version
84 CApi_*Smoke programs, CApi_TeardownLifetime_* (seven modes), CApi_StressSmoke, CApi_HandleRegistry, CApi_BoundaryDetailCNA_BUILD_C_API=ON with CNA_ENABLE_NET=ONRoute behaviour, refusals, handle recycling, process-exit teardown
CApi_InstalledConsumerC API build, Linux/ELF, 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 25-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 external bindings (cna-cs, cna-java, cna-ts, cna-python, cna-rust, cna-swift, cna-go, cna-ruby) are separate repositories on their own cadence. Each targets ABI 0.21.x while TARGET exports 0.29.0 after two incompatible steps; five refuse a 0.29.0 library by exact-version rules and none is qualified against it (per-binding table on the bindings boundary). For a C API change this means: (a) a 0.x minor is not backwards compatible although find_package and CONSUMING.md (“every minor within a major is additive”) suggest otherwise, so 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 Python loader refuses a missing export by name); (c) 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#, 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. 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; assert behaviour.
  • 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: outputs cleared first, barrier, kind and thread, 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: each binding's generation, the fact that none is qualified against this ABI, and any removal or renumbering 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