The web target: Emscripten build contract, browser loop, storage and renderer evidence

CNA snapshot 009d40f5  ·  Deep Dives › Cross-platform engineering  ·  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. Checked by reading the Emscripten CMake, Game.cpp, StorageDevice.cpp, ENetBackend.cpp, the browser harness scripts and the web workflows at 009d40f5, and Sharp Runtime next @ 41b918c9 for isolated storage. Nothing was built, served or run in a browser; save volatility is a reading of the code.

CNA has no separate JavaScript implementation: its web build sends the same C++23 framework through Emscripten and links it with SDL3's browser port. WebAssembly keeps most of the language and loses much of the process model, so the hard parts of a web port sit at its boundaries: the exception and suspension ABI every object must share, a loop the browser schedules, content that has to be put into a virtual file system, saves that live in memory, sockets that cannot listen, and a verdict that has to leave a process whose exit status nobody can observe. This page explains each boundary at this snapshot and then separates the kinds of evidence that exist for the browser renderers. The task-level walk-throughs are Tutorial 81, Tutorial 124 and Tutorial 105.

The toolchain changes the execution contract

A web configure runs under emcmake (the web preset names no toolchain file, so it is run as emcmake cmake --preset web). The platform implementation is SDL3; CNA_PLATFORM=EMSCRIPTEN is a reserved name that is refused. With no explicit choice the renderer is WEBGL2, one of five identities that exist only under Emscripten (WEBGL1, WEBGL2, CANVAS, HTML_DOM, SVG_DOM); the native GL profiles OPENGLES2, OPENGLES3 and OPENGL33 are refused with a message that points to the WebGL identities (RendererSelection.cmake), and the Windows-only identities and METAL are refused by their own host gates. No rule limits an Emscripten configure to the five browser identities: WEBGPU has a browser route through the emdawnwebgpu port, and identities that need no operating-system API, such as SOFTWARE, HEADLESS and STUB, are not refused by name (no workflow builds them for the web). One configure is not limited to one renderer: CNA_GRAPHICS_RENDERERS can link several browser renderers into one bundle and let the page choose at run time, which is what the multi-renderer web workflow builds.

When the default identity is WEBGL1 or WEBGL2, cna_apply_emscripten_renderer_link_contract() links the executable with -sMIN_WEBGL_VERSION and -sMAX_WEBGL_VERSION both pinned to 1 or 2, so a WebGL 2 build never silently runs GLSL ES 3.00 in a WebGL 1 context and a WebGL 1 build is never quietly upgraded; Canvas, DOM and WebGPU families inherit no GL requirements from it. EasyGL, the sibling library behind both WebGL identities, is configured with EASYGL_EMSCRIPTEN_EXCEPTION_MODEL=JS when embedded, so it joins CNA's exception ABI.

One exception ABI for every frame

CNA uses C++ exceptions in ordinary error paths, and under Emscripten every C++ frame that can propagate one has to use the same exception model. BuildPerformance.cmake defines cna_emscripten_exception_abi as JavaScript-lowered exceptions (-fexceptions when compiling, -fexceptions -sDISABLE_EXCEPTION_CATCHING=0 when linking), links it privately to every CNA-owned target, and publishes it through cna_build_config (CNA::BuildConfig; the exception interface is also exported as CNA::EmscriptenExceptionAbi) so a consumer's own translation units and final link use the same model (modules/CMakeLists.txt). The same interface adds -sSTACK_SIZE=1048576; CNA's own web example targets additionally link -sALLOW_MEMORY_GROWTH=1. Earlier revisions used native WebAssembly exceptions (-fwasm-exceptions); that choice interacted with the old main loop as described below, and it is not the model at this snapshot.

Exceptions that escape before the loop starts need special care on the web. Game::Run() catches std::exception around initialisation, logs it and rethrows (Game.cpp), because an exception that leaves Run() reaches the browser as a rejected promise carrying only an opaque exception pointer, with no type, no what() and nothing in the console. On a native build the extra line is harmless.

Asyncify is an executable policy, not a library property

Blocking Game::Run() on the web needs the WebAssembly stack to be suspended and resumed between frames, which Emscripten's Asyncify provides. It is a separate interface target, cna_emscripten_asyncify (-sASYNCIFY=1), and it is applied only to CNA-owned EXECUTABLE targets, unless a target sets the property CNA_EMSCRIPTEN_ASYNCIFY to OFF. The C API's WebAssembly artifact does exactly that and adds -sASYNCIFY=0: a JavaScript-driven library must keep ordinary synchronous exports and let the browser schedule its per-frame calls, and a CheckWasmLinkContract script checks that no later flag re-enables it (C API CMake). An application outside CNA's tree that uses a blocking Game::Run() must link CNA::EmscriptenAsyncify itself; CNA::EmscriptenAbi is the compatibility composition of the exception ABI and Asyncify. The test executable asks for a different mechanism: CnaTests links -sJSPI=1 and -sEXIT_RUNTIME=1 so that loopback networking tests could yield to Node's event loop and the process exits after printing its results (see configuring CNA: Emscripten). It is also an executable without the opt-out, so it receives -sASYNCIFY=1 as well, and by a reading of the link options plus a one-file probe with a newer emsdk, emcc lets the later flag win: the binary would be an Asyncify build and the JSPI request has no effect (CNA-BUG-262; the CnaTests link itself was not run).

Threads are an application-wide ABI choice

A default web build is single-threaded. CNA_ENABLE_EMSCRIPTEN_THREADS (default OFF, and a configure error on any non-Emscripten target; CMakeLists.txt) switches the whole application to the shared-memory pthread ABI: -pthread on every compile and link, Sharp Runtime's own web thread support forced on, and on the final link -sOFFSCREEN_FRAMEBUFFER=1 and -sWASMFS=1. The WasmFS choice has a recorded reason: the legacy JavaScript file system proxies every syscall of a pthread to the browser main thread, which can deadlock a background Content.Load while the game loop is active (the comment names Firefox), whereas WasmFS keeps preloaded-file operations inside WebAssembly. Threaded and single-threaded archives cannot be linked together, so SDL's persistent install has two roots, .sdl-prebuilt-emscripten and .sdl-prebuilt-emscripten-pthreads. The browser offers shared memory only to cross-origin-isolated pages; providing those response headers is the host page's job, not something the build emits (see Tutorial 78).

Content has to cross into the virtual file system

A native executable can find files beside its working directory. A page cannot: Emscripten exposes a virtual file system, and a file exists there only if the page creates it or the linker packages it. ContentManager resolves an asset through std::filesystem and then through the platform's file service, and both look into that virtual tree. In CNA's own tree exactly two build sites package content, both with --preload-file <dir>/Content@/Content: the 2D graphics demo (graphics examples) and the sound demo (audio examples). There is no project-wide asset manifest, no --embed-file policy and no custom shell file that fills the gap. A successful link therefore proves nothing about content: each web application must account for every name that can reach ContentManager or TitleContainer, and two builds of the same commit can carry different content if their preload mappings differ.

Saves live in memory unless the page adds persistence

StorageDevice resolves its root independently of the platform implementation (StorageDevice.cpp, EnsureStorageRoot): $XDG_DATA_HOME/<game>, else $LOCALAPPDATA/<game>, else $HOME/.local/share/<game> (~/Library/Application Support/<game> on Apple), else a directory under the current path. Under Emscripten those variables come from the JavaScript runtime's default environment rather than from a user session, so the root is an ordinary directory in the virtual file system: in-memory MEMFS by default, and WasmFS, whose default backend is also in memory, in a threaded build. CNA's sources contain no IDBFS mount and no FS.syncfs call, and nothing bridges its storage to localStorage or IndexedDB. Reading the code, a save therefore succeeds, is readable for the rest of the page's life, and is gone after a reload; this was not executed for this page. The SDL3 platform's preference path (GetPreferencesPath, through SDL_GetPrefPath) is also inside the virtual file system, and StorageDevice does not use it.

Sharp Runtime's separate System::IO::IsolatedStorage takes a different route: on Emscripten its root is /save/.cna_isolated_storage, its source comment expects the application's startup code to mount IDBFS at /save, and closing an isolated-storage stream calls FS.syncfs (sharp-runtime next @ 41b918c9, StoragePaths.cpp and IsolatedStorageFileStream.cpp). CNA's StorageDevice does not go through it, and no CNA code mounts /save. A game that needs durable browser saves therefore has to add them itself, either through the browser's storage APIs or by mounting a persistent file system over the path it writes and synchronising after writes; both are application code and neither is verified here. Tutorial 124 shows the browser-storage route.

Networking changes topology, not only transport

A browser tab can open outbound connections but can never listen, and Emscripten's socket layer does not report a usable ephemeral port. ENetBackend.cpp therefore hosts on the fixed port 61191 instead of ENET_PORT_ANY (meaningful only for a Node-run relay or server build) and rebuilds the joining side as an outbound-only ENet client; UDP-broadcast discovery is compiled out, so Find returns nothing; and EndJoin does not pump the handshake synchronously on the web. A web build can keep SystemLink message transport while losing native hosting and LAN discovery, so network evidence must name the role and the host, not only "ENet linked". The session-level consequences are on network sessions: the web.

The browser loop at this snapshot

On the desktop, Game::RunLoop() ticks while RunApplication is true. Under Emscripten it stores the game in one static slot and alternates one frame body, EmscriptenMainLoopCallback(), with CNA_WaitForAnimationFrame(), an EM_ASYNC_JS function that awaits requestAnimationFrame. Asyncify suspends and resumes the same WebAssembly stack between frames, so Run() blocks until the game exits and then returns, as it does natively (a fatal exception inside a frame is the exception, see below and CNA-BUG-080): a stack-local MyGame game; game.Run(); is valid, and after the loop Run() calls EndRun() and AfterLoop(). The source comment records why the frame body is called directly: a separately registered browser callback could not re-enter the instance while Run()'s stack is suspended.

The frame body is still not the desktop Tick. By reading Game.cpp: time comes from the platform's GetTicksMilliseconds() rather than the performance counter; a frame's delta is capped at a hard-coded 250 ms rather than MaxElapsedTime (500 ms); every frame drains whole TargetElapsedTime steps, so IsFixedTimeStep = false is not honoured; IsRunningSlowly is set false before every update; a frame draws only if at least one update ran, and SuppressDraw() is never consulted; Exiting is raised inside the frame body; a frame exception is caught, logged and ends the loop without Exiting; and the mobile suspend handling is not part of this path. None of these stops a demo from drawing, and each can change game behaviour, tests, shutdown or timing-sensitive networking, which is why they belong to the compatibility contract. The full comparison is on one frame source trace and game time and the timestep; the control flow is on the Game class.

A historical case: the hand-off that destroyed the game

The current design exists because the earlier one failed in an instructive way; CNA's own emscripten-mainloop-game-lifetime.md records the former implementation and the contract that replaced it. Earlier revisions (alpha.1 among them) ended Run() in emscripten_set_main_loop with simulateInfiniteLoop = 1, which Emscripten implements as a raw JavaScript throw 'unwind'. Under native WebAssembly exceptions, the cleanup landing pad generated for a local object with a destructor caught that foreign throw, so a stack-local game was destroyed at the call site while the registered callback still held pointers into it, including the graphics device manager. The page built, loaded and started; the first scheduled frame then touched dead state.

The way the fault was located is the reusable part (a historical account; the tree still holds the investigation as the spike spikes/emscripten-mainloop-stack-spike, whose README records the hypothesis ruled out first, the sanitizer and SAFE_HEAP runs and the decisive repro.cpp, with a build line that still uses -fwasm-exceptions, the ABI CNA no longer uses, and as GameServiceContainerTests.cpp, which the investigation added for the multiple-inheritance case). The first hypothesis blamed pointer adjustment across multiple inheritance in the graphics manager, which looked plausible because a stale-looking address crossed an interface boundary. Focused reproductions under AddressSanitizer, UndefinedBehaviorSanitizer with vptr checks and Emscripten's SAFE_HEAP did not support it. An isolated lifetime reproduction did: the decisive evidence was temporal rather than structural, the sequence constructor, hand-off, destructor, callback. That located the defect in the hosting contract shared by every web renderer instead of in any one renderer. The interim workaround allocated the game with new and never deleted it, and that migration carried a second lesson: a search-based fix must end with a search for survivors, not with a count of edited files, because an entry point that is built only for the web is easy to miss. At this snapshot the benchmark constructs its game on the stack, as do most entry points; 28 example entry points, among them demo_2d and demo_sound, the two demos that package content for the web, instead allocate the game with new and delete it after Run() returns. Both forms are correct under the Asyncify loop, because Run() returns normally; only the old never-delete workaround is obsolete. The general rule survives the fix: a function that syntactically does not return may still run cleanup machinery, so ownership has to follow the host's actual control flow.

How to test a host-owned loop

A useful regression test cannot stop at "the loop was entered". It must keep the page alive until at least one frame has run, assert inside that frame that the game object and its device are alive, and report through a channel the browser harness can read (a named window flag), because a WebAssembly process exit code is not observable the way a native child's is. It should separately exercise exit and shutdown if those semantics are claimed: at this snapshot that means checking that Exiting, EndRun() and AfterLoop() run and that Run() returns. CNA's lifetime document states the same requirement for its runtime browser checks (several Update()/Draw() frames, Exit(), a return from Run(), normal destruction) and that merely compiling the page does not verify the contract. The minimum diagnostic trace is small and worth logging whenever a web port misbehaves: construction, loop entry, each suspension, first frame, exit request, destruction.

What a successful web build establishes

A completed Emscripten link is real evidence: the selected source closure is accepted by the web toolchain, the C++23 surface compiles with that SDK, and the selected renderers' symbols resolve. It does not establish that runtime content was packaged, that saves survive a reload, that a frame was reached after the browser took control, that generated WebGL shaders compiled in a browser, or that the page displayed the selected renderer rather than merely loading its module.

A bare Node launch is a poor substitute for a browser. DOM renderers need window and document, SDL-backed renderers need browser host services, and WebGL needs a real graphics context, so errors such as "window is not defined" or an SDL initialisation failure under Node say that the wrong host was used, not that a renderer's drawing semantics were tested. Node remains the right host for renderer-independent code, which is how CnaTests.js runs.

A reproducible web evidence record names at least: the CNA commit, the emsdk version (CI pins 6.0.3), the renderer identity or set, the configure options that change the ABI (threads, an Asyncify opt-out), the target, the produced .html, .js and .wasm, every preload mapping, and the host level reached: linked, launched under Node, or served over HTTP to a named browser. Those are ascending levels, not synonyms.

Renderer evidence on the web: four rungs

The five browser identities are not five equally verified implementations. Four kinds of evidence exist, and each rung includes more of the delivery chain without making the lower tests redundant:

  • Build: the renderer compiled and linked to WebAssembly.
  • Host start: a JavaScript host loaded the module far enough to run startup.
  • Structure: a real browser observed renderer-specific DOM or SVG objects and invariants that another renderer could not trivially impersonate.
  • Pixels: the browser's compositor produced expected colours at sampled screen positions.

A pixel sample can prove presentation while missing a malformed object tree; a structural assertion can prove the renderer identity while missing blending. The strongest suite does both.

IdentityBuildBrowser runStructurePixels
HTML_DOMCIautomatic: htmldom-ci.yml, six pages in headless Chromiumyesscreenshot samples
SVG_DOMCI (multi-renderer bundle)manual runner onlyyes (SVG namespace)four scissor-order samples
WEBGL2CI (multi-renderer bundle)no harness in the treenono
WEBGL1admitted by the CMake logic; built by no workflowno harness in the treenono
CANVASCI (multi-renderer bundle)no harness in the tree; a written manual checklistnono

'CI' in the Build column means a workflow is configured to build that identity, not a recorded passing run: both web workflows (htmldom-ci.yml and emscripten-multi-renderer-ci.yml) pin a sharp-runtime revision that predates the components this snapshot requests, so by a static reading neither can configure at TARGET (CNA-BUG-199). The table is deliberately asymmetric. A build entry says nothing about frame one, and a handful of sampled pixels says nothing about complete rendering parity; what the table gives each claim is a precise meaning, and it shows the shortest path to stronger evidence for each identity.

HTML_DOM: automated browser and compositor evidence

htmldom-ci.yml pins emsdk 6.0.3, installs Playwright with Chromium, starts Xvfb, configures HTML_DOM and runs run-htmldom-test-suite.sh, which builds cna_test_htmldom_all and serves six pages over loopback HTTP on distinct ports: smoke, pixel, stress, dispose, host-integration and memory (the workflow's header comment still says four). The driver waits for a verdict the page publishes on window (for example __cnaSmokeDone and __cnaSmokeResult) and prints passed against expected checks. For pixels it takes a real Playwright screenshot of the composited page, decodes it inside the page into a canvas and reads points with getImageData (inside and outside a scissor rectangle, an atlas interior and a point near its edge), so the evidence is what Chromium finally showed, not what the renderer's DOM claims (htmldom-browser-test.mjs). The host-integration page proves that CNA refuses to seize a host-owned DOM element and creates no viewport of its own; it rewrites the HTML response with Playwright's page.route() because injecting through addInitScript runs too late relative to module bootstrap (htmldom-host-integration-test.mjs). The timing mechanism is part of the proof. This workflow checks out an older pinned sharp-runtime revision than the snapshot's component closure needs, so by a static reading it stops at configure time at this snapshot and is configured to do this rather than recorded as passing (CNA-BUG-199).

The compositor checks use bounded per-channel tolerances rather than exact matches. In the smoke page the point at logical (70,70), inside both the sprite and the scissor rectangle, must be within 12 of pure red on each channel and the point at (90,90), outside it, within 12 of the CornflowerBlue clear colour (100, 149, 237); in the pixel page the atlas interior at (12,21) and the point two pixels inside the drawn region's right edge at (39,21) must both be within 25 of pure red. Sample points are offset by the bounding rectangle of #cna-dom-root, and the wait for the page's verdict is bounded by CNA_HTMLDOM_TEST_TIMEOUT_MS (default 60000). The edge check is deliberately as tight as the interior one because the DOM path is expected to show no bleed at that point, unlike the Canvas 2D path (see Targets and readback). The SVG_DOM driver applies the same style of bound, 20 per channel, at its four scissor-order points. Read from htmldom-browser-test.mjs and svgdom-browser-test.mjs at 009d40f5; not executed.

SVG_DOM: a real browser harness without a caller

svgdom-browser-test.mjs drives three pages (smoke, pixel, scissor-order) through Playwright. It checks that the root is a real <svg> in the SVG namespace and that every sprite-bearing flush-slot child is an SVG-namespace element, that the SDL backbuffer canvas stays hidden, and it samples four decisive composited points for the draw-order regression. The namespace checks are anti-spoofing evidence: an HTML or canvas renderer cannot satisfy them by producing a similar picture. No workflow or CTest registration calls its shell runner, so SVG_DOM has a reproducible manual browser route plus the automatic bundle link, not a continuous compositor test.

WEBGL1, WEBGL2 and CANVAS: translation without browser execution

emscripten-multi-renderer-ci.yml configures WEBGL2;CANVAS;HTML_DOM;SVG_DOM into one bundle, checks with llvm-nm that expected symbols are in the built archives and counts the descriptor entries in the generated registry; it launches no browser. Nothing in the tree runs WEBGL1, WEBGL2 or CANVAS in a browser, so context creation, shader-version compatibility, uploads, presentation and context loss are open for them. In particular, no GLSL generated for WEBGL1 has been compiled by a real WebGL 1 implementation, and the WebGL renderers' context-loss handling exists in source but no test deliberately loses and restores a context and then demands a fresh visible frame. The WEBGPU identity also has a browser route, driven by the manual scripts run-webgpu-browser-test.sh and run-webgpu-parity-test.sh rather than by a workflow (see Tutorial 132).

 emcmake configure + build          Wasm + JS (+ .data from --preload-file)
 (exception ABI, Asyncify,   ---->  one renderer or a multi-renderer bundle
  optional pthread ABI)                           |
                                                  v
                                   served over HTTP (not file://)
                                                  |
                                                  v
                                           browser page
                                                  |
                                                  v
          Game::Run() on the Wasm stack: init -> frame body -> await rAF -> ...
          (Asyncify suspends between frames; Run() returns after exit)
                                                  |
                                                  v
          WEBGL1/2 (EasyGL) | CANVAS | HTML_DOM | SVG_DOM | WEBGPU
                                                  |
               +------------------+---------------+----------------+
               v                  v                                v
       window verdict flag   DOM / SVG structure         compositor screenshot
       (page -> harness)     (renderer identity)         samples (pixels)
Figure. The browser execution chain at this snapshot. An Emscripten build with CNA's exception ABI and Asyncify produces WebAssembly, JavaScript and optional preloaded content, which must be served over HTTP to a browser page. Game::Run() runs on the WebAssembly stack and is suspended between frames while the browser schedules animation frames, then returns after exit. The selected browser renderer draws, and three separate observations can be made: a verdict the page publishes on window, renderer-specific DOM or SVG structure, and pixels sampled from the compositor's screenshot. Loading a page, reaching a frame, engaging the intended renderer and verifying composited pixels are separate milestones.

A common browser-verification shape

All browser identities can converge on one harness pattern, which the HTML_DOM and SVG_DOM drivers already follow in part: serve the artifacts over loopback HTTP; wait for a named window verdict with a timeout; fail on console errors and page exceptions; assert a renderer-specific structural token; sample a few composited pixels from a real screenshot; and archive the page log and screenshot. Renderer-specific cases then add shader compilation, context restoration, DOM ownership or canvas behaviour. The central rule is to prove that the intended runtime engaged: a green build can be produced without a browser, a green page before any frame, and a plausible image by the wrong rendering route. Identity, execution and visible output are three separate facts, and the evidence should contain all three. The cross-platform framing is on the cross-platform contract.

Running the browser drivers by hand: how Playwright is found

The three browser drivers, htmldom-browser-test.mjs, htmldom-host-integration-test.mjs and svgdom-browser-test.mjs, are ES modules, and Node's ES-module resolver ignores NODE_PATH. Playwright is usually installed globally rather than inside the repository. Each driver therefore builds a CommonJS loader with createRequire(import.meta.url) and loads playwright through it, and the shell launchers (run-htmldom-browser-test.sh, run-svgdom-browser-test.sh) start Node with NODE_PATH="$(npm root -g)". Started directly with node and no NODE_PATH, a driver does not find a global Playwright even when it is installed, so run it through its launcher or export the variable yourself.

The drivers launch Playwright's own headless Chromium. A missing browser download is therefore an environment failure, not a renderer result. The C API's browser runner, run_browser_tests.mjs, resolves Playwright the same way. Before it falls back to an ordinary require, it also tries two fixed global module directories, and it accepts PLAYWRIGHT_CHROMIUM as the path of a replacement Chromium executable. Checked by reading at 009d40f5; not executed.

Read in this order

  1. BuildPerformance.cmake: the exception ABI, Asyncify and the per-target opt-out.
  2. modules/CMakeLists.txt: what cna_build_config publishes to consumers, including the pthread ABI.
  3. Game.cpp: Run, RunLoop and EmscriptenMainLoopCallback.
  4. StorageDevice.cpp: where saves go.
  5. run-htmldom-test-suite.sh and htmldom-browser-test.mjs: the one automated browser suite.
  6. emscripten-multi-renderer-ci.yml: what the bundle job checks and what it does not run.

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

Tests and validation
Test architecture