Thread and callback map

CNA snapshot 009d40f5  ·  Development › Human Takeover  ·  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 from source at 009d40f5; no test, sanitizer or build was executed. Real-host behaviour (Android sensors, other renderer families, binding runtimes) is not established here.

CNA has a serial Game loop but it is not a single-threaded process. Audio devices invoke callbacks or run workers, foreign-language hosts enter the C ABI, loading code adds components and constructs graphics resources, native platforms deliver window-system events and signals, sensors call back from platform sessions, and the Inspector runs its own network thread. This map lists which code can run while another object is changing, which barriers the source at the snapshot actually establishes (with the tests that exercise them) and, just as deliberately, where it establishes nothing. Do not turn “normally called from Tick” into an undocumented thread-safety guarantee.

⚠

Absence is not a promise. A lock that guards one list, one registry or one callback path is a proven barrier for that path only. Nothing at this snapshot documents GraphicsDevice, ContentManager, Game as a whole, the input snapshots or any platform backend as callable from an arbitrary thread; where a header or a document says so explicitly (for example the sensor and C ABI contracts below) the statement is quoted with its exact limits. Everything on this page was checked by reading the source at snapshot 009d40f5; no test, sanitizer or build was run for it.

The principal execution contexts

ContextSource-established behaviourMaintenance rule
Game loop callerGame::Run calls RunLoop, which calls Tick until RunApplication is false. Each Tick advances the clock (a fixed-step game sleeps and yields until a step is due), calls PollEvents, runs the fixed-step Update passes, then BeginDraw, Draw and EndDraw. On Emscripten the same frame body runs in a loop on the Run() stack, suspended between frames by an Asyncify wait (Game.cpp); the C ABI's wasm build is instead driven one frame at a time from JavaScript (the design note).Keep renderer-context work and Game state changes on this path unless a subsystem below documents a transfer.
Platform eventsOne IPlatform::PollEvents call per frame fills a caller-owned batch on the loop thread. SDL3 and SDL2 drain their queues with SDL_PollEvent; Win32 dispatches the calling thread's message queue; X11 and Wayland read their connections; Terminal turns a signal flag into events (Platform events and input).Separate OS callback entry from the frame's public input snapshot; never write high-level state from a signal handler.
Audio callback or workerThe SDL3 and SDL2 devices hand SDL a feed callback that SDL invokes while holding its stream or device lock; the NULL and ALSA devices run a CNA worker thread that calls the buffer callback. IAudioDevice::Stop and Close are documented callback barriers; mixer locks, the SDL3 generation counter and deferred cleanup mediate the rest (Audio callback barriers).Before freeing userdata, prove that future and in-flight callbacks cannot read it.
Loading threadA loading screen may add components from another thread (componentListsMutex_), and ContentManager may be used to build graphics resources there; a constructed ContentReader holds a renderer context lease for the whole decode on the GL renderers (Game, renderer, content).Treat “background loads work” as permission to load, not to draw, reset or dispose concurrently.
Sensor or OS callbackAccelerometer and Gyroscope readings arrive from a platform sensor session (SDL3: an event watch); Compass and Motion callbacks come from Android-only backends (Sensor callbacks).Do not promote the local barriers to a blanket instance guarantee.
C ABI host threadHandleRegistry records each handle's creation thread and checks it in every typed lookup, Release and the user-tag accessors; the last-error record is thread-local (C ABI gates).A mutex around the registry does not permit arbitrary off-thread CNA calls or garbage-collector-thread destruction.
User callbacksGame virtual hooks, component Update/Draw, BufferNeeded, sensor handlers, file-dialog callbacks, C lifecycle callbacks and foreign delegates can re-enter owner logic.Do not hold a non-reentrant lock while calling code you do not control; document callback-scoped borrows.
Diagnostics and InspectorThe optional Inspector agent owns one background network thread that calls the diagnostics provider on demand; the provider's locks are the only coupling to the frame path (Diagnostics and Inspector threads).Everything the agent reads must stay valid while the game thread mutates it.

A search of the non-test module sources for thread construction (std::thread, std::async, std::jthread, pthread_create, OpenMP pragmas) finds only these creation sites: the NULL and ALSA playback workers and the ALSA recording worker under modules/audio, the Android sensor bridge worker (AndroidSensorBridge.cpp), the Inspector agent and its web bridge (Agent.cpp, WebBridge.cpp; the bridge detaches one thread per connection), the Phone HTTP notification listener (HttpNotificationChannel.cpp) and a stderr-drain thread in the content-pipeline host process (HostProcess.cpp). No renderer and no platform backend constructs a thread. That is a statement about CNA's own code: SDL's audio thread, the browser, the operating system and the caller's own threads exist outside it.

What the source proves and what it leaves open

Object or pathProven barrier at the snapshotNot established
Game component listscomponentListsMutex_ guards the ordered lists and the order-changed token maps; the frame copies under it and runs component code unlocked. GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem.GameComponentCollection's own storage has no lock; removal from another thread during a frame; Game::Exit from another thread (two plain bool members).
GL context useEasyGL and OpenGL4 return a recursive-mutex context lease; BeginDraw…EndDraw holds one for the frame and every ContentReader holds one for a decode.Any lock in GraphicsDevice itself; a lease on the other renderer families; concurrent draw, reset or dispose.
Input snapshotsNone, by design: InputManager and TextInputEXT are documented as game-loop-thread only.Any background-thread read or write.
Audio device callbackIAudioDevice contract: Stop waits for the in-flight callback, Close guarantees no later call; AudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers for NULL and, when selected, SDL3 and ALSA; Sdl2AudioDeviceTests.OpenIsPausedAndStopIsACallbackBarrier.A callback that blocks, allocates or throws is a contract violation, not something the device repairs. The suite's ALSA case runs only when libasound is available (it uses ALSA's own null device); SDL2 has its own barrier test.
Mixer teardownSDL3: generation bump, device stop/close, deferred-track drain, then native destroy; CNA mixer (ALSA pairing): never torn down, one recursive lock shared with the device callback.Track use after DestroyMixer by code that does not check the generation.
Dynamic sound streamsFrameworkDispatcher::Streams under StreamsMutex; DynamicSoundEffectInstance submission under queueMutex_; FrameworkDispatcher::Update deliberately calls stream updates unlocked.A BufferNeeded handler that takes a lock the dispatcher's caller holds.
Accelerometer, GyroscopePer-class mutex, snapshot of registration records, in-flight dispatch token that Dispose waits on; docs/devices-thread-safety.md.Dispose concurrent with Start on one instance; subscribing or unsubscribing an event from another thread mid-raise beyond “does not crash”.
Compass, MotionGeneration-checked shared control block; the lock is never held while user code runs.A callback already past its check on one thread while another thread completes destruction (documented as unsupported); Android hardware behaviour beyond the fake-backend seam.
C ABI handlesGeneration, kind and creation-thread checks under the registry mutex; thread-local last error.Thread safety of any object a handle names; child-before-parent destruction from a finalizer thread.
CNB loader registrystd::shared_mutex; lookups return the loader by value.The XNB ContentTypeReaderManager registries and the ContentManager asset cache have no lock in source.

Game and component mutation

Game::Tick runs on the thread that called Run(). Update ends with FrameworkDispatcher::Update(), so dynamic-audio buffer refills and media-player state changes also happen on the loop thread. A suspended mobile game parks in WaitWhileSuspended, sleeping 16 ms between event polls instead of drawing. Game::Exit() only sets RunApplication and suppressDraw_, both plain bool members with no synchronization, so an exit request belongs on the loop thread; the C ABI's cna_game_request_exit is likewise a creation-thread call that is allowed from inside a lifecycle callback.

The header comment in Game.hpp records why component mutation is guarded: the XNA loading-screen pattern builds the next screen's components on a background thread and adds them to Game.Components, so the two ordered lists are written from a thread other than the one running Update/Draw. Game::Update and Game::Draw copy updateableComponents_ and drawableComponents_ into currentlyUpdatingComponents_ and currentlyDrawingComponents_ under componentListsMutex_, release it, and then call each component's own Update/Draw with no lock held, so game code never runs under it. The mutex is recursive because categorizing a component sorts it and a component's order-changed event re-enters the same path. CategorizeComponent and both sort helpers take it, and it also covers the two order-changed token maps.

OnComponentRemoved takes the lock, removes the component from the ordered list and nulls matching entries in the in-flight snapshot. Nulling instead of erasing keeps the running loop's iteration valid (both loops skip null entries), and it means a component removed and freed by the update that is currently running — the screen-manager pattern — is not called later in the same frame (Game.cpp). GameTest.AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame exercises that case.

What the guard does not cover

  • The snapshot loops iterate without the lock. The nulling therefore protects removal performed by code running on the loop thread; it does not synchronize a removal made from another thread while the loop is iterating.
  • GameComponentCollection (GameComponentCollection.cpp) has no lock of its own: items_, owned_ and the ComponentAdded/ComponentRemoved events are plain. The supported scenario is the one the test builds — one loader thread adds while the game thread only runs the Update/Draw snapshot passes.
  • OnComponentAdded calls the component's Initialize() on the adding thread, outside the lock, before categorizing it. A loader that adds a component runs that component's initialization on the loader thread.
  • GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem adds 200 components from one thread while the test thread calls Update/Draw in a loop, then requires every component to have been updated and drawn (it skips when the selected platform cannot create a window; GameTests.cpp). It demonstrates one supported cross-thread addition; it does not establish every Game method as thread-safe.

A maintainer altering the snapshot or the collection events must test removal during a callback, background add and destruction — not merely stable iteration. The historical lifetime defect around component snapshots is the subject of Case study: Game component lifetime; the ownership side is in the ownership map.

Platform events and input

The platform contract is one PollEvents call per frame that fills a caller-owned vector; there is no per-event virtual dispatch and no background event thread in any backend. Game::PollEvents then feeds every event to the input bridge before acting on it, advances the keyboard and mouse snapshot services once, and pumps gamepad and joystick only when that subsystem has been asked for. Input state is unsynchronized on purpose: the InputManager comment says writes come from PlatformInputBridge::ProcessEvent during Game::PollEvents(), reads come from Update()/Draw() on the same thread, and Set*/Get* must not be called from a background thread; TextInputEXT events are documented the same way. A background thread that needs input must be handed values by the game loop.

Platform events are not one global policy. Each backend's specifics, all read at the snapshot:

  • SDL3 and SDL2. Sdl3Platform::PollEvents and Sdl2Platform::PollEvents drain SDL_PollEvent. Process-global SDL state is serialized: SDL3 uses one heap-allocated, never-destroyed mutex that records its owning thread (Sdl3Synchronization.hpp; the recorded owner lets ~Sdl3Platform avoid re-locking on the thread that already holds it, the fix for an exit()-under-lock hang); SDL2 uses a function-local mutex in Sdl2Platform.cpp. See SDL3 platform internals. MakeCurrent and SwapBuffers do not take that lock, and it is not a promise that using them from another thread is supported.
  • Win32. Win32Platform::PollEvents drains the calling thread's whole message queue with PeekMessageW(nullptr, …, PM_REMOVE), including messages for other windows created on that thread, and window procedures run synchronously inside DispatchMessageW, filling the platform's pending list (Win32Platform.cpp). Windows delivers a window's messages to the thread that created it, so polling belongs on the thread that created the CNA windows (Win32 platform internals).
  • X11. The class comment in X11Platform.hpp states that XInitThreads() is deliberately not called, that a host wanting full Xlib thread safety must call it before constructing the platform, and that every Xlib call goes through one per-instance mutex. A search of modules/platform/src/X11 finds no such mutex: the only mutex there is the process-wide policy mutex in X11Error.cpp, which guards the error-handler chain and the list of owned displays (error traps are tracked per thread). Treat the per-instance serialization sentence as not backed by code that could be read; the enforceable rule is one-thread use of the platform with events polled once per frame. PollXEvents uses XPending so the loop never blocks; controllers, the desktop portal and the tray are pumped from the same call (X11 platform internals).
  • Wayland. WaylandPlatform::PollEvents pumps the connection with the prepare-read/dispatch protocol, generates key repeats, and reports a lost compositor connection once as a QuitEvent. The frame pacer's Wait (called during presentation) runs the same prepare-read dance on its own event queue and leaves events for the application's queue unread until the next PollEvents (WaylandGraphicsServices.cpp; Wayland platform internals).
  • Terminal. Two signal contexts exist and both are deliberately tiny. TerminalResizeWatcher's SIGWINCH handler sets a single volatile sig_atomic_t; TerminalPlatform::PollEvents consumes it, reads the size and creates the Resized and PixelSizeChanged events on the polling thread (TerminalResizeWatcher.cpp; a second watcher in the same process is refused). TerminalSession installs restoring handlers for nine signals whose only work is write(2) of a fixed epilogue and tcsetattr(3), after which the previous disposition is reinstated and the signal is re-raised (TerminalSession.cpp). Nothing else in the Terminal sources takes a lock (Terminal platform internals).
  • Asynchronous services. Native file dialogs are callback-shaped for a reason the header states: the callback fires exactly once per call, on the thread that pumps events, after the Show* call has returned (the native WIN32 service is the known exception: its modal dialogs call back before Show* returns, CNA-BUG-182) (IPlatformSystemServices.hpp).

Before moving window creation, resize or input processing to another thread, read that backend's event path; the sources give no platform a permission to do so.

Renderer and native context affinity

GraphicsDevice holds mutable render state, the resource registry and the selected renderer and presentation objects, and the class takes no lock around public state changes. Treat the game and render path as the supported default. Two mechanisms nevertheless exist for the case that matters most, background content loading on a GL context:

  • Thread context lease. IGraphicsRenderer::AcquireThreadContextLeaseEXT returns an IRendererThreadContextLease (IGraphicsRenderer.hpp). The base implementation returns null; the header says native GL renderers use the lease to serialize a whole content decode against frame rendering while moving their context between threads, and that other families return none because their APIs support concurrent resource creation directly or own a different synchronization boundary. At the snapshot only the EasyGL and OpenGL4 renderers override it, each with a per-renderer recursive mutex and a thread_local nesting record: the first acquisition on a thread records the previous platform binding and makes the renderer's context current, and the last release on that thread restores or releases the binding. A lease therefore has to be released on the thread that acquired it; a release with no matching thread-local record logs an error and returns without unlocking (EasyGLRenderer.cpp, OpenGL4Renderer.cpp).
  • Who takes it. GraphicsDeviceManager::BeginDraw stores AcquireRendererThreadContextLeaseForFrame() (a lease whose release policy gives up the renderer's own binding so another thread can acquire it) and EndDraw presents and releases it, also on an exception; a Disposing handler releases it if the game disposes the device from Draw (GraphicsDeviceManager.cpp). GraphicsDevice::Clear and Present take a short-lived lease (GraphicsDevice.cpp), and every ContentReader acquires one in its constructor and keeps it until it is destroyed (ContentReader.cpp). Consequence read from the source, for the two GL renderers: a background load that constructs a reader waits while a frame holds the lease, and a frame waits while a load holds it.

EasyGL also makes the calling thread usable: EnsureCallingThreadContext republishes the profile, makes the platform context current and initializes meta-gl's thread-local dispatch before creating a texture, sprite batch, occlusion query or render target, because ContentManager may construct resources on a loading thread. The mutual exclusion is kept on Emscripten too, where every GL call is proxied to one browser-thread context and an upload's bind/upload pair could otherwise be split by the frame's own binds; only the binding hand-over is platform-specific. The GL context service itself documents that a context binding belongs to the calling thread (IPlatformGlContext.hpp).

The lease is not evidence that GraphicsDevice is generally thread-safe, and the other families do not share one policy:

  • Vulkan. No CPU-side lock guards renderer state in VulkanRenderer.cpp (its only atomic caches adapter sample counts). Its synchronization is GPU-side: resource handles (textures, render targets and, per a source comment, vertex-buffer handles) are retired into a fence-gated queue and freed once the consuming frame's fence has completed.
  • SDL_gpu. A source comment in SdlGpuModern.cpp states that the renderer is single-threaded, so its storage-buffer registry takes no lock; the one mutex in the renderer guards the optional shader-cross-compiler session count.
  • HEADLESS. Its resource registry (the list of live resources) is mutex-guarded; that is a bookkeeping lock, not a rendering contract.

A neutral GraphicsDevice or renderer-interface change must therefore be reviewed across EasyGL (EasyGL internals), Vulkan (Vulkan internals) and SDL_gpu (SDL_gpu internals), not inferred from whichever backend happens to have a mutex. The device-level summary is Context and thread rules. Native handles a renderer borrows from the platform must stay valid through frame submission and present, regardless of which thread initiates shutdown.

Content loading threads

Loading is the one place where the source expects off-loop work. What is and is not synchronized:

  • A ContentReader takes the renderer lease (above) when its manager has a graphics device; on renderers that return no lease the reader takes nothing.
  • The CNB loader registry is a process-wide table guarded by a std::shared_mutex; lookups return the loader by value so a concurrent registration that rehashes cannot invalidate the caller's copy (CnbLoaderRegistry.hpp).
  • The XNB reader registries in ContentTypeReaderManager.cpp are function-local unordered_maps with no lock, and ContentManager's asset cache has none either. Registration is done at Game construction (GameTest.ConstructionRegistersBuiltInXnbReadersBeforeLoadContent); registering or clearing readers while another thread loads is not covered by any barrier read here.

The cache and format route is described in Content runtime internals; the cache lifetime rules are in the ownership map.

Audio callback barriers and reentrancy

All playback devices implement the IAudioDevice contract. Open returns a paused device and retains shared ownership of the buffer callback until Close; Stop pauses and waits for an in-flight callback, so after it returns no callback is active or begins until the next Start; after Close returns the callback is never invoked again. The callback may run on a real-time thread and must not block, allocate or throw, and device lifecycle methods must not be called from it. Concretely:

  • SDL3 device. Open publishes every callback-visible field (stream, callback, scratch buffer, format) while SDL's stream is still paused and callbackEnabled_ is false. Start sets the flag and resumes the stream; StopLocked clears the flag, pauses the stream, and then locks and unlocks SDL's stream lock, which SDL holds while the feed callback runs, so the lock/unlock pair is the barrier that makes Stop's postcondition real (Sdl3AudioDevice.cpp). The SDL2 device has the same shape with SDL_LockAudioDevice.
  • NULL and ALSA devices. Both run one CNA worker thread. StopLocked clears the running flag and joins the worker; Close then releases the PCM, callback and buffers (AlsaAudioDevice.cpp, NullAudioDevice.cpp). The NULL worker is paced with a timed wait so it does not spin.
  • SDL3 mixer engine. GetMixer and DestroyMixer share one mutex, held for the whole function; the device callback (MIX_Generate) does not take it. GetMixer opens the device paused, configures the callback with the mixer and only then starts it. DestroyMixer increments the atomic generation first, stops and closes the device, drains deferred track destruction, destroys the native mixer and quits SDL_mixer; instances compare the generation before touching a track (AudioMixer.cpp, MixerEngine.cpp). A track destroyed from SDL_mixer's stopped callback is queued on a lock-free list and freed at the next safe engine entry, because freeing it would release the stream whose lock the native stop frame still owns; the post-mix callback's storage is immortal and replacement or removal is a callback barrier.
  • CNA mixer engine (ALSA pairing). Every facade call and the device callback hold one recursive mutex, recursive because the XNA layer holds MixerLock while calling back into the facade; mixer callbacks therefore run under that lock, on the device thread or, for an explicit stop, on the caller's. The engine is never torn down (generation is always 1); an atexit handler closes the device without holding the lock, since Close waits for a callback that may be waiting for it (CnaMixer MixerEngine.cpp). A long facade call on the game thread can delay the audio callback, and a slow callback can stall the facade.
  • Per-track state. SoundEffectInstance keeps its FilterState behind a unique_ptr, so moving the instance moves ownership without changing the address the mixer callback was given (SoundEffectInstance.cpp). The stopped callback of a fire-and-forget sound cannot free its pan state, because the mixer may still deliver the already-pulled final buffer to the cooked callback afterwards; the state is pushed onto a lock-free stack that the next Play() drains and a static destructor drains at exit. This code is backend-neutral (SoundEffect.cpp).

Dynamic streams and reentrancy. FrameworkDispatcher::Update copies Streams under StreamsMutex and then updates each instance with the lock released, because BufferNeeded is raised from DynamicSoundEffectInstance::Update on the loop thread and a handler may dispose its own stream, which locks StreamsMutex in StopInternal; holding the (non-recursive) lock across the update would deadlock (FrameworkDispatcher.cpp; FrameworkDispatcherTest.UpdateDoesNotDeadlockWhenBufferNeededDisposesTheInstance). The opposite move is equally unsafe: taking a callback out of a lock without retaining its owner invites a use-after-free. Record both the lock path and the ownership path. DynamicSoundEffectInstance (DynamicSoundEffectInstance.cpp) marks SubmitBuffer and SubmitFloatBufferEXT as producer-thread entry points: the queue is guarded by queueMutex_, the float-mode flag is atomic after a sanitizer-found race, and StopInternal destroys the track under the same lock.

Run the device conformance suite (AudioDeviceConformanceTests.cpp), the mixer suites and, for SDL3, the standalone mixer-destroy harnesses registered in cmake/Harnesses.cmake under AddressSanitizer when modifying any of this; the engine-level picture is Audio engine internals.

Sensor callbacks and disposal

CNA's contract for the four sensor classes and VibrateController starts from the archived Windows Phone floor, “instance members are not guaranteed to be thread safe”, and lists what CNA adds (docs/devices-thread-safety.md). Those additions are backed by specific locks and by concurrency tests that CNA's own document reports were run under its devices-tsan preset; that run was not repeated for this page.

  • Accelerometer and Gyroscope. One PlatformSensorSubsystem<T> per sensor type (PlatformSensorSubsystem.hpp) owns a single platform session and a vector of shared registration records under its mutex. The platform callback takes a snapshot of the records under the lock and dispatches with it released; DispatchToInstances rechecks each record's owner and pushes the calling thread's id onto the instance's dispatch token before calling in. Dispose unregisters the instance and then waits, on a condition variable, until no other thread's id remains in the token, so a handler that disposes its own sender does not wait on itself. Stop moves the session out under the lock and destroys it after releasing it, because the session's destructor is a callback barrier and an in-flight callback may be waiting for that mutex (Accelerometer.cpp). Handler exceptions are caught at the dispatch boundary, recorded and not propagated.
  • SDL3 sensor session. The SDL3 platform implements the session with an SDL event watch and its own barrier: a closing flag, a list of active callback thread ids, and a destructor that removes the watch and waits until no other thread is inside the callback. The current thread is excluded so a handler may dispose its own sender, in which case a shared state reference closes the native sensor after the callback returns (Sdl3DeviceServices.cpp). CNA does not assume which thread SDL calls the watch on; it records it.
  • Compass and Motion. These have backends only on Android (elsewhere getIsSupportedProperty() is false and a backend exists only if a test installs one). Each owns a shared_ptr<SensorOwnerControlBlock> holding a mutex, a generation and a nullable owner pointer; every backend callback captures a copy of the block and the generation active when it was registered, never this. A callback locks the block, returns if the generation moved on or the owner is null, and calls user code only after releasing the lock. Dispose(true) nulls the owner under the lock before stopping the backend. The header records the accepted boundary: a callback already past its check on another thread while a different thread completes the owner's destruction remains unsupported (SensorOwnerControlBlock.hpp), and unlike Accelerometer's dispatch token, Compass::Dispose itself has nothing to wait on for other threads' callbacks: any wait comes from the backend's own Stop. The event-contract document leaves the real Android backend chain unverified beyond the fake-backend seam.

The devices page (Devices and sensor lifetime) traces both models. Do not promote these local barriers to a blanket instance guarantee: the contract document explicitly excludes Dispose() racing Start() on the same instance (a narrow window between Dispose's read of started_ and its Stop() call), and it guarantees no more for subscribing or unsubscribing an event from another thread mid-raise than that it does not crash. Handler-list mutation from within a handler, reentrant dispatch and destruction during dispatch are separately specified in docs/devices-event-contract.md, which also states that CNA fixes no thread identity for sensor events: a caller must not assume any event fires on the thread that called Start().

Foreign callback and C API gates

The C ABI states its baseline conservatively: all runtime lifecycle, game, graphics, content, input, media, device and resource-destruction operations are called on the runtime's creation thread, which is also the graphics thread of the initial game loop, and wording that promises more must come with dedicated tests (docs/c-api/CALLBACKS_AND_THREADING.md). The implementation:

  • Handles. A CNA_Handle is a slot index plus generation in HandleRegistry (CnaCApiDetail.hpp). One mutex protects slot metadata. Create records the calling thread. The typed Get<T> checks generation, then kind (CNA_RESULT_INVALID_HANDLE), then creation thread (CNA_RESULT_THREAD); GetUserTag, SetUserTag and Release check the thread too, and only GetKind does not. Release moves the object out under the lock and lets the last reference die after the lock is released (CnaCApiDetail.cpp). The mutex makes registry bookkeeping coherent; it does not make any object a handle names thread-safe, and the native object may itself be thread-affine.
  • Last error. The LastError record (result, category, message, and the join and sensor error ids) lives in a thread_local variable. Query and copy the diagnostic on the thread that saw the failure; a later failure on that thread replaces it.
  • Lifecycle callbacks. CGame adapts the C callback table and the separate frame-hook table into Game virtuals (CnaCApiRuntime.cpp). Each invocation sets a plain isInsideCallback_ flag (safe only because every caller is on the creation thread), runs the callback inside an exception barrier, clears the flag and invalidates the borrowed graphics-device handle. A failing callback latches CNA_RESULT_CALLBACK, calls Exit() and skips later callbacks and the base Update/Draw passes. GetCallableGame refuses run, run_one_frame, tick and destroy from inside a callback (CNA_RESULT_INVALID_STATE) while request_exit, clear, set_window_title and the hook setter stay allowed. Only one C-owned game may be active per process; counters for owned children live under a separate runtime-state mutex.
  • Contexts and registrations. CNA copies the callback table but does not own void* context memory: the owner keeps a context valid until the registration is successfully removed or its source object has finished destruction. Event registrations are the deliberate exception to child-before-parent destruction: they do not block cna_game_destroy, are invalidated after the disposal event is raised, and remain the caller's to release once. Throwing a C++ exception or using longjmp across a CNA frame, or freeing a live callback context, is unsupported.
  • Foreign runtimes. A garbage-collector finalizer thread that destroys a handle receives CNA_RESULT_THREAD and, in a naive wrapper, loses the only copy of it; wrappers must not use a finalizer or cleaner thread as a substitute for the required destruction thread, and must keep a callback's managed delegate or global reference alive while native code can invoke it. The C# binding at its pinned revision queues off-thread releases for the owner thread (C# binding internals); the Java binding attaches a native thread to the JVM when a callback arrives on one (Java binding internals). Those pages are evidence for their own revisions, not for the snapshot.

The layer-by-layer policies are in C API internals and the user-level handle model is in the C API guide. The pure-C tests that pin the thread rules are named on the C API internals page; whether the C API library builds at the snapshot was not verified here.

Diagnostics and Inspector threads

The diagnostics provider is process-wide state guarded by several mutexes (metric, frame, frame-completion, thread and history), with per-producer-thread event rings drained at frame completion, snapshot capture and thread exit (Diagnostics lock and thread map). The optional Inspector Agent owns exactly one background network thread that answers authenticated requests by calling the provider, and its header states that it never executes on an instrumentation or render thread (Agent.hpp). The coupling to the game thread is lock contention inside the diagnostics module: a large snapshot copied on the agent thread can delay frame completion, but network backpressure stops at the agent thread (Inspector threads and locks). An injected resource-preview provider is called on the agent thread; the header requires it only to start or poll asynchronous readback, return promptly and never wait for a render device or queue, and the agent cannot enforce more.

Before changing a callback

Write five answers in the patch description: who invokes it and on what thread; which locks are held; whether it can call back into CNA; who owns its userdata and for how long; and what Stop, Close or Dispose does to in-flight calls. If the source cannot answer one, mark it unknown and add a test or instrumentation. A callback bug rarely ends at the callback body: it often lives in creation, generation change or teardown. Two further checks are specific to CNA:

  • Which barrier is it? Name it from the tables above — a mutex, a generation counter, a callback-barrier Stop/Close, a thread-id token, a deferred-free list or creation-thread affinity — and check that the change keeps it on both the normal and the failure path (a throwing handler, a handler that disposes its own sender, a rebuilt mixer).
  • Is the “fix” only quieting a sanitizer? Several barriers here exist because a sanitizer found a real use-after-free or race (the pan-state list, the dynamic-stream flag, the platform stack at process exit). Reproduce with the matching sanitizer configuration named in What to test after changing X rather than reasoning from a clean read.

This chapter maps established paths; it does not certify the thread safety of any backend or public method, and it does not replace the source for a specific change. The lifetime companion is the ownership map; the wider blast-radius questions are on Blast radius and readiness.

What to read to verify the map

  1. Game.cpp: Tick, Update, Draw, CategorizeComponent, OnComponentRemoved, PollEvents, RunLoop; then the guarded-list comment in Game.hpp.
  2. IGraphicsRenderer.hpp (IRendererThreadContextLease), then the two lease implementations, GraphicsDeviceManager::BeginDraw/EndDraw and the ContentReader constructor.
  3. IAudioDevice.hpp and the selected device, then the selected mixer engine, SoundEffect.cpp and FrameworkDispatcher.cpp.
  4. PlatformSensorSubsystem.hpp, Compass.cpp and the two contract documents.
  5. CnaCApiDetail.hpp, CnaCApiRuntime.cpp and CALLBACKS_AND_THREADING.md.

Tests that pin parts of the map (present in the snapshot; not executed for this page): GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem and AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame; AudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers and AlsaAudioDevice.OpensTheNullDeviceAndStopIsABarrier; CnaMixer.ATrackDestroyedFromItsOwnStoppedCallbackLeavesTheMixAndIsFreedLater; DynamicSoundEffectInstanceTest.StressProducerConsumerWithRandomPauseStopDispose; FrameworkDispatcherTest.UpdateDoesNotDeadlockWhenBufferNeededDisposesTheInstance; for sensors the per-class ConcurrentStartStopFromMultipleThreadsDoesNotCrash, ConcurrentDisposeFromMultipleThreadsNeverCorruptsInstanceCount, DisposeFromWithinOwnCallbackDoesNotDeadlock and AccelerometerTests.NoDispatchAfterDispose; and the C ABI's wrong-thread checks in the pure-C audio smoke test. Each proves a narrow contract in its own configuration; several skip when no window or audio device is available. The device, test-target and CTest details are in the Test target index, and the change-to-test matrix is What to test after changing X.

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

Tests and validation
What to test after changing X