From d5b7f180ac10cdbce9191224efd8190df6b5060c Mon Sep 17 00:00:00 2001 From: binwenw Date: Wed, 11 Nov 2026 19:09:39 +0800 Subject: [PATCH] wasm: harden the GPU surface and renderer Review of the WebGL2 integration found real bugs in the texture path and pixel-size rounding, plus gaps in what the smoke test could prove. Texture path: - updateTexture built its source view as stride * h, but CUI hands the pointer to the first pixel of the damaged rect (cui_text.c:880), so the span ends at the last pixel of the last row: (h - 1) * stride + w * fmt. The old math read up to x * fmt bytes past the end of the atlas when the rect was not flush left, and threw outright when the source ended near the top of the wasm heap. Same formula CUI's runtime.js uses. - createTexture passed wasm address 0 to texImage2D when CUI creates an empty texture with a null pixel pointer (a fresh emoji atlas does), which uploaded the heap's first bytes as pixels instead of leaving it empty. Pixel size: - PixelWidth/PixelHeight truncated while CUI rounds (vw * scale + 0.5f), so a fractional display scale (1.5) disagreed by a pixel and the renderer refused the frame. Both sides now round, and GLRenderer follows the draw data for the canvas backing store instead of throwing when they drift. - resize() rounds the same way. Surface contract: - GetDrawData now throws ObjectDisposedException: cui_get_draw_data returns &ui->draw_data, so a destroyed context yields a plausible pointer into a freed struct rather than null. BeginFrame's summary, clobbered when CreateGpu was inserted, is restored; Incremental now rejects a GPU surface even when the value is unchanged. Host and test: - The scratch-canvas webgl2 probe is gone: it spent the one attempt the retry loop existed to repeat and under --disable-gpu could silently turn the whole fallback decision. The retry now asks the real canvas. - test-browser-smoke.py asserts the renderer the page reports (--renderer webgl2|2d), so a silent software fallback can no longer pass as the GPU path, and the Pages workflow smoke-tests both renderers and gains a pull_request trigger with the deploy jobs gated to main. - New GpuSurfaceTests pin the contract on desktop CI: atlas upload on first text, in-place update for new glyphs, draw-data lifetime after dispose, the software-only guards, callback validation, and the 1.5-scale pixel rounding. Verified: 697 + 28 tests and format green; both smoke paths pass with the renderer assertion active; a live headless run typed glyphs missing from the atlas and observed two texSubImage2D calls with no exception. --- .github/workflows/pages.yml | 18 +- .../CuiSharp.Gallery.Browser/BrowserHost.cs | 11 +- .../CuiSharp.Gallery.Browser/wwwroot/main.js | 72 +++--- src/CuiSharp.Tests/GpuSurfaceTests.cs | 221 ++++++++++++++++++ src/CuiSharp/CuiSurface.cs | 27 ++- tools/test-browser-smoke.py | 26 ++- 6 files changed, 327 insertions(+), 48 deletions(-) create mode 100644 src/CuiSharp.Tests/GpuSurfaceTests.cs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ca404b2..00ad8d8 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -3,6 +3,16 @@ name: GitHub Pages on: push: branches: [main] + pull_request: + # The Pages tree is built by a separate pipeline from the solution; a browser regression + # should be caught before merge, and the deploy jobs below are gated to main. + paths: + - 'samples/CuiSharp.Gallery.Browser/**' + - 'src/CuiSharp/**' + - 'native/**' + - 'tools/build-cui-wasm.ps1' + - 'tools/test-browser-smoke.py' + - '.github/workflows/pages.yml' workflow_dispatch: permissions: @@ -59,12 +69,16 @@ jobs: $bytes = (Get-ChildItem $site -Recurse -File | Measure-Object Length -Sum).Sum Write-Host "Pages payload after removing redundant sidecars: $bytes bytes" - - name: Smoke-test the exact Pages tree in headless Chrome - run: python tools/test-browser-smoke.py artifacts/pages/wwwroot + - name: Smoke-test the exact Pages tree in headless Chrome (both renderers) + run: | + python tools/test-browser-smoke.py artifacts/pages/wwwroot --renderer webgl2 + python tools/test-browser-smoke.py artifacts/pages/wwwroot --renderer 2d - uses: actions/configure-pages@v5 + if: github.ref == 'refs/heads/main' - uses: actions/upload-pages-artifact@v4 + if: github.ref == 'refs/heads/main' with: path: artifacts/pages/wwwroot diff --git a/samples/CuiSharp.Gallery.Browser/BrowserHost.cs b/samples/CuiSharp.Gallery.Browser/BrowserHost.cs index 210af32..362a99b 100644 --- a/samples/CuiSharp.Gallery.Browser/BrowserHost.cs +++ b/samples/CuiSharp.Gallery.Browser/BrowserHost.cs @@ -109,12 +109,9 @@ public static void Frame(double timestampMilliseconds) if (_gpu) { // GPU mode: present the draw data pointer to JS, which draws it with WebGL2. - // No CPU rasterization, no pixel buffer copy. - nint dd = surface.GetDrawData(); - if (dd != 0) - { - PresentGpu(dd, surface.PixelWidth, surface.PixelHeight); - } + // No CPU rasterization, no pixel buffer copy. The viewport is inside the draw + // data, so JS needs nothing but the pointer. + PresentGpu(surface.GetDrawData()); } else { @@ -239,7 +236,7 @@ public static void Shutdown() private static partial void Present(int pixelAddress, int width, int height, int x, int y, int dirtyWidth, int dirtyHeight); [JSImport("canvas.presentGpu", "main.js")] - private static partial void PresentGpu(nint drawData, int width, int height); + private static partial void PresentGpu(nint drawData); private static Key MapKey(string code) { diff --git a/samples/CuiSharp.Gallery.Browser/wwwroot/main.js b/samples/CuiSharp.Gallery.Browser/wwwroot/main.js index 78c16d7..ece21f1 100644 --- a/samples/CuiSharp.Gallery.Browser/wwwroot/main.js +++ b/samples/CuiSharp.Gallery.Browser/wwwroot/main.js @@ -64,10 +64,10 @@ const VERTEX_SIZE = 64; // sizeof(cui_vertex) const CMD_SIZE = 28; // sizeof(cui_draw_cmd) on wasm32: 4-byte pointers class GLRenderer { - constructor(canvas, smoke) { + constructor(canvas, keepDrawingBuffer) { const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, - premultipliedAlpha: true, preserveDrawingBuffer: keepBuffer, + premultipliedAlpha: true, preserveDrawingBuffer: keepDrawingBuffer, }); if (!gl) throw new Error('WebGL2 is not available'); this.gl = gl; @@ -80,11 +80,14 @@ class GLRenderer { return s; }; const p = gl.createProgram(); - gl.attachShader(p, sh(gl.VERTEX_SHADER, VS)); - gl.attachShader(p, sh(gl.FRAGMENT_SHADER, FS)); + const vs = sh(gl.VERTEX_SHADER, VS), fs = sh(gl.FRAGMENT_SHADER, FS); + gl.attachShader(p, vs); + gl.attachShader(p, fs); ['a_pos', 'a_uv', 'a_local', 'a_half', 'a_radii', 'a_params', 'a_color', 'a_bcolor'].forEach((n, i) => gl.bindAttribLocation(p, i, n)); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('CUI program: ' + gl.getProgramInfoLog(p)); + gl.deleteShader(vs); + gl.deleteShader(fs); this.program = p; this.uViewport = gl.getUniformLocation(p, 'u_viewport'); this.uTex = gl.getUniformLocation(p, 'u_tex'); @@ -116,8 +119,11 @@ class GLRenderer { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0); - if (fmt === 1) gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, heapBytes(px, w * h)); - else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, heapBytes(px, w * h * 4)); + // CUI creates empty textures with a null pixel pointer and fills them in through + // texture_update later (a fresh emoji atlas does exactly that), so null has to stay + // null: a view starting at wasm address 0 would upload the heap's first bytes as pixels. + if (fmt === 1) gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, w, h, 0, gl.RED, gl.UNSIGNED_BYTE, px ? heapBytes(px, w * h) : null); + else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, px ? heapBytes(px, w * h * 4) : null); const id = this.nextTex++; this.textures.set(id, { t, fmt }); return id; @@ -128,8 +134,12 @@ class GLRenderer { gl.bindTexture(gl.TEXTURE_2D, e.t); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); gl.pixelStorei(gl.UNPACK_ROW_LENGTH, stride / e.fmt); + // px addresses the first pixel of the damaged rect, not the start of a full row of it, + // so the span ends at the last pixel of the last row. stride * h would read up to + // x * fmt bytes past the end of the source for a rect that is not flush left - and + // throw outright when the source ends near the top of the wasm heap. gl.texSubImage2D(gl.TEXTURE_2D, 0, x, y, w, h, e.fmt === 1 ? gl.RED : gl.RGBA, gl.UNSIGNED_BYTE, - heapBytes(px, stride * h)); + heapBytes(px, (h - 1) * stride + w * e.fmt)); gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0); } destroyTexture(id) { @@ -138,7 +148,7 @@ class GLRenderer { } // draws the cui_draw_data at `dd` (a wasm heap pointer) - draw(dd) { + draw(dd, canvas) { const gl = this.gl, buf = heapBuffer(); const i32 = new Int32Array(buf), u32 = new Uint32Array(buf), f32 = new Float32Array(buf); const verts = u32[dd >> 2], vcount = i32[(dd + 4) >> 2]; @@ -147,7 +157,11 @@ class GLRenderer { const W = i32[(dd + 24) >> 2], H = i32[(dd + 28) >> 2]; const cc = u32[(dd + 32) >> 2]; // r | g<<8 | b<<16, straight from cui_color const atlas = u32[(dd + 36) >> 2]; - if (W !== canvas.width || H !== canvas.height) throw new Error(`draw data ${W}x${H} != canvas ${canvas.width}x${canvas.height}`); + // The drawing buffer has to match the frame CUI laid out or the viewport clips it. Follow + // the draw data instead of trusting the resize path to agree on how a fractional display + // scale rounds: a backing store resized here is valid at once, and the alternative - + // refusing the frame - loses the whole canvas over a one-pixel disagreement. + if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; } gl.viewport(0, 0, W, H); gl.clearColor((cc & 255) / 255, ((cc >> 8) & 255) / 255, ((cc >> 16) & 255) / 255, 1); gl.clear(gl.COLOR_BUFFER_BIT); @@ -245,8 +259,10 @@ function resize() { const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); const scale = Math.max(1, window.devicePixelRatio || 1); - const pixelWidth = Math.max(1, Math.floor(width * scale)); - const pixelHeight = Math.max(1, Math.floor(height * scale)); + // Rounded, matching CUI's own width_px = vw * scale + 0.5f, so the canvas and the frame + // CUI lays out agree at fractional display scales such as 1.5. + const pixelWidth = Math.max(1, Math.round(width * scale)); + const pixelHeight = Math.max(1, Math.round(height * scale)); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; @@ -273,10 +289,10 @@ function present(pointer, width, height, x, y, dirtyWidth, dirtyHeight) { context.putImageData(imageData, 0, 0, x, y, dirtyWidth, dirtyHeight); } -function presentGpu(dd, width, height) { +function presentGpu(dd) { if (!gpu) return; if (query.has('debug')) window.__cuiDD = dd; - gpu.draw(dd); + gpu.draw(dd, canvas); } function animationFrame(timestamp) { @@ -290,7 +306,6 @@ function animationFrame(timestamp) { smokeState = 5; } else if (smoke && smokeState === 6) { const typedNameHash = hashNameField(); - smokeState = 5; status.style.display = 'grid'; const passed = typedNameHash !== selectedNameHash; smokeState = 7; @@ -392,22 +407,19 @@ async function start() { const exports = await dotnetRuntime.getAssemblyExports(dotnetRuntime.getConfig().mainAssemblyName); host = exports.CuiSharp.Gallery.Browser.BrowserHost; - // Renderer choice: WebGL2 by default, software with ?renderer=software or - // where WebGL2 is missing. The surface has to exist before the first frame. - // Under --disable-gpu (CI) the first webgl2 request can race the GPU - // process coming up and return null; a short retry covers that, and the - // later attempt does succeed. - let supported = false; - if (wantGpu) supported = !!document.createElement('canvas').getContext('webgl2'); - if (supported) { - for (let attempt = 0; attempt < 12 && !gpu; attempt++) { - try { - gpu = new GLRenderer(canvas, smoke); - } catch (error) { - gpu = null; - if (attempt === 11) console.error('WebGL2 renderer failed to initialize:', error); - else await new Promise(resolve => setTimeout(resolve, 250)); - } + // Renderer choice: WebGL2 by default, software with ?renderer=software or where WebGL2 + // is unavailable. The surface has to exist before the first frame, so the choice is made + // here. Under --disable-gpu the first webgl2 request can race the GPU process coming up + // and return null; the retry covers that and the later attempt succeeds. Ask the real + // canvas directly - probing a scratch one would spend the one attempt the retry exists + // to repeat, and a failed request leaves the canvas free to take a 2d context. + for (let attempt = 0; wantGpu && !gpu && attempt < 12; attempt++) { + try { + gpu = new GLRenderer(canvas, keepBuffer); + } catch (error) { + gpu = null; + if (attempt === 11) console.error('WebGL2 renderer failed to initialize:', error); + else await new Promise(resolve => setTimeout(resolve, 250)); } } await host.Initialize(!!gpu); diff --git a/src/CuiSharp.Tests/GpuSurfaceTests.cs b/src/CuiSharp.Tests/GpuSurfaceTests.cs new file mode 100644 index 0000000..1edfac7 --- /dev/null +++ b/src/CuiSharp.Tests/GpuSurfaceTests.cs @@ -0,0 +1,221 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using CuiSharp.Interop; + +namespace CuiSharp.Tests; + +/// +/// A GPU surface produces draw data and owns no pixels: the host's renderer consumes the +/// frame, and CUI reaches the host through the texture callbacks. These pin what the CPU can +/// observe about that contract, so a desktop CI leg catches a break before a browser does. +/// +public sealed unsafe class GpuSurfaceTests +{ + // The frame tests need the native library, which a checkout may not have built yet; + // same probe LocalizationTests uses. + private static readonly bool NativeReady = CanLoadNative(); + + private static bool CanLoadNative() + { + try + { + _ = Native.KeyName(Key.A); + return true; + } + catch (Exception error) when (error is DllNotFoundException or BadImageFormatException or EntryPointNotFoundException) + { + return false; + } + } + [Fact] + public void AFrameCarriesDrawDataAndUploadsTheFontAtlas() + { + if (!NativeReady) return; + + var calls = (TextureCalls*)NativeMemory.AllocZeroed((nuint)sizeof(TextureCalls)); + try + { + using var surface = CuiSurface.CreateGpu(320, 200, Iface(calls)); + + surface.BeginFrame(); + surface.Ui.Label("hello"); + surface.EndFrame(); + + // The frame is built for a renderer to draw; the renderer is what the counts feed. + Assert.True(surface.CommandCount > 0); + Assert.NotEqual(0, surface.GetDrawData()); + + // CUI uploads its glyph atlas through the interface on the first text it draws, + // which is the reason a GPU surface cannot take a null callback. + Assert.True(calls->Created > 0, "the font atlas should have been created"); + Assert.Equal((int)TextureFormat.R8, calls->LastCreateFormat); + Assert.True(calls->LastCreatePixels != 0, "the atlas arrives with its pixels"); + Assert.True((long)calls->LastCreateWidth * calls->LastCreateHeight > 0); + } + finally + { + NativeMemory.Free(calls); + } + } + + [Fact] + public void RedrawingTextUpdatesTheExistingAtlasInsteadOfReplacingIt() + { + if (!NativeReady) return; + + var calls = (TextureCalls*)NativeMemory.AllocZeroed((nuint)sizeof(TextureCalls)); + try + { + using var surface = CuiSurface.CreateGpu(320, 200, Iface(calls)); + + surface.BeginFrame(); + surface.Ui.Label("aaa"); + surface.EndFrame(); + int afterFirstFrame = calls->Created; + + // New glyphs extend the atlas: they arrive as a partial update of the texture + // already created, which is what the renderer's update callback has to survive. + surface.BeginFrame(); + surface.Ui.Label("bbb"); + surface.EndFrame(); + + Assert.Equal(afterFirstFrame, calls->Created); + Assert.True(calls->Updated > 0, "new glyphs should update the atlas in place"); + Assert.True(calls->LastUpdatePixels != 0); + Assert.True(calls->LastUpdateWidth > 0 && calls->LastUpdateHeight > 0); + Assert.True(calls->LastUpdateStride >= calls->LastUpdateWidth); + } + finally + { + NativeMemory.Free(calls); + } + } + + [Fact] + public void SoftwareOnlyMembersRefuseAGpuSurface() + { + if (!NativeReady) return; + + using var surface = CuiSurface.CreateGpu(64, 64, Iface(null)); + + Assert.Equal(RendererKind.Gpu, surface.Kind); + Assert.Null(surface.LastDamage); + var pixels = new byte[surface.PixelWidth * surface.PixelHeight * 4]; + Assert.Throws(() => surface.Render(pixels)); + Assert.Throws(() => surface.Incremental = true); + } + + [Fact] + public void ASoftwareSurfaceStillReportsItsOwnKind() + { + if (!NativeReady) return; + + using var surface = CuiSurface.Create(64, 64); + + Assert.Equal(RendererKind.Software, surface.Kind); + Assert.NotEqual(0, surface.GetDrawData()); + } + + [Fact] + public void MissingTextureCallbacksAreRejectedRatherThanLeftToCrashLater() + { + // CUI calls texture_create unconditionally for the font atlas, so a surface without + // the callbacks would fail on the first glyph instead of here. + var partial = new NativeRendererIface + { + TextureCreate = (nint)(delegate* unmanaged[Cdecl])&CreateTexture, + TextureUpdate = (nint)(delegate* unmanaged[Cdecl])&UpdateTexture, + }; + + Assert.Throws(() => CuiSurface.CreateGpu(64, 64, partial)); + } + + [Fact] + public void DrawDataIsNotReadableAfterDispose() + { + if (!NativeReady) return; + + var surface = CuiSurface.CreateGpu(64, 64, Iface(null)); + surface.Dispose(); + + // cui_get_draw_data returns &ui->draw_data, so a destroyed context yields the field's + // offset inside a freed struct: a plausible pointer to nothing. + Assert.Throws(() => surface.GetDrawData()); + } + + [Fact] + public void PixelSizeRoundsTheWayCuiRoundsTheFrame() + { + if (!NativeReady) return; + + // cui_draw_data.width_px is vw * scale + 0.5f; a surface that truncated would disagree + // by a pixel at 1.5 and a renderer comparing the two would refuse every frame. + using var surface = CuiSurface.Create(1001, 601, scale: 1.5f); + + Assert.Equal(1502, surface.PixelWidth); + Assert.Equal(902, surface.PixelHeight); + } + + // ---- the host side of the contract ------------------------------------- + + [StructLayout(LayoutKind.Sequential)] + private struct TextureCalls + { + public int Created; + public int Updated; + public int Destroyed; + public int LastCreateFormat; + public int LastCreateWidth; + public int LastCreateHeight; + public nint LastCreatePixels; + public int LastUpdateWidth; + public int LastUpdateHeight; + public int LastUpdateStride; + public nint LastUpdatePixels; + } + + private static NativeRendererIface Iface(TextureCalls* calls) => new() + { + User = (nint)calls, + TextureCreate = (nint)(delegate* unmanaged[Cdecl])&CreateTexture, + TextureUpdate = (nint)(delegate* unmanaged[Cdecl])&UpdateTexture, + TextureDestroy = (nint)(delegate* unmanaged[Cdecl])&DestroyTexture, + }; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static nuint CreateTexture(void* user, int w, int h, int format, void* pixels) + { + if (user != null) + { + var calls = (TextureCalls*)user; + calls->Created++; + calls->LastCreateFormat = format; + calls->LastCreateWidth = w; + calls->LastCreateHeight = h; + calls->LastCreatePixels = (nint)pixels; + return (nuint)calls->Created; + } + + return 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void UpdateTexture( + void* user, nuint texture, int x, int y, int w, int h, void* pixels, int stride) + { + if (user == null) return; + var calls = (TextureCalls*)user; + calls->Updated++; + calls->LastUpdateWidth = w; + calls->LastUpdateHeight = h; + calls->LastUpdateStride = stride; + calls->LastUpdatePixels = (nint)pixels; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void DestroyTexture(void* user, nuint texture) + { + if (user == null) return; + ((TextureCalls*)user)->Destroyed++; + } +} diff --git a/src/CuiSharp/CuiSurface.cs b/src/CuiSharp/CuiSurface.cs index 2be54a7..f7917d0 100644 --- a/src/CuiSharp/CuiSurface.cs +++ b/src/CuiSharp/CuiSurface.cs @@ -100,10 +100,17 @@ public Ui Ui public RendererKind Kind { get; } /// Framebuffer width in pixels. - public int PixelWidth => (int)(_width * _scale); + /// + /// Rounded, not truncated, because that is how CUI computes cui_draw_data.width_px + /// (cui_core.c: vw * scale + 0.5f). A renderer that sizes its target by + /// truncating disagrees by a pixel on fractional display scales such as 1.5, and a scaler + /// comparing the two would refuse every frame. + /// + public int PixelWidth => (int)(_width * _scale + 0.5f); /// Framebuffer height in pixels. - public int PixelHeight => (int)(_height * _scale); + /// Rounded like . + public int PixelHeight => (int)(_height * _scale + 0.5f); /// /// What the last frame this surface drew cost: the same numbers as , but @@ -181,7 +188,8 @@ public static CuiSurface CreateGpu( ArgumentOutOfRangeException.ThrowIfLessThan(height, 1); if (textures.TextureCreate == 0 || textures.TextureUpdate == 0 || textures.TextureDestroy == 0) { - throw new ArgumentException("a GPU surface needs texture create, update and destroy callbacks"); + throw new ArgumentException( + "a GPU surface needs texture create, update and destroy callbacks", nameof(textures)); } var desc = new NativeCtxDesc @@ -197,6 +205,8 @@ public static CuiSurface CreateGpu( Native.SetViewport(ctx, width, height, scale); return new CuiSurface(0, ctx, width, height, scale, RendererKind.Gpu); } + + /// Starts a frame. Call when the UI is built. /// /// runs layout and paint, so by the time the next /// frame starts the previous one's deferred canvas callbacks and arena @@ -296,8 +306,9 @@ public bool Incremental get => _incremental; set { - if (_ctx == 0 || _incremental == value) return; + if (_ctx == 0) return; if (_sw == 0) throw new InvalidOperationException("incremental rendering is a software-renderer feature."); + if (_incremental == value) return; _incremental = value; Native.SwSetIncremental(_sw, (byte)(value ? 1 : 0)); } @@ -370,7 +381,13 @@ public int CommandCount /// A GPU renderer reads vertices, indices, commands and the font atlas id straight out of /// this structure — the same cui_draw_data the built-in renderers consume. /// - public nint GetDrawData() => Native.GetDrawData(_ctx); + public nint GetDrawData() + { + // cui_get_draw_data returns &ui->draw_data: on a destroyed context that is not null, + // it is the offset of the field inside a freed struct. + ObjectDisposedException.ThrowIf(_ctx == 0, this); + return Native.GetDrawData(_ctx); + } /// public void Dispose() diff --git a/tools/test-browser-smoke.py b/tools/test-browser-smoke.py index ea9a81b..f8bd8e9 100644 --- a/tools/test-browser-smoke.py +++ b/tools/test-browser-smoke.py @@ -79,7 +79,7 @@ def evaluate(ws, expression: str, command_id: int, deadline: float, errors: list return None, command_id -def run(site: Path, timeout_seconds: float) -> None: +def run(site: Path, timeout_seconds: float, expect_renderer: str) -> str: site = site.resolve() if not (site / "index.html").is_file(): raise FileNotFoundError(f"No index.html in published site: {site}") @@ -151,7 +151,10 @@ def send(method: str, params: dict | None = None) -> None: send("Runtime.enable") send("Page.enable") - send("Page.navigate", {"url": base_url + "?page=2&smoke=1"}) + url = base_url + "?page=2&smoke=1" + if expect_renderer == "2d": + url += "&renderer=software" + send("Page.navigate", {"url": url}) expression = """(() => { const canvas = document.querySelector('#surface'); @@ -188,12 +191,21 @@ def send(method: str, params: dict | None = None) -> None: if "BROWSER-SMOKE-OK" in status: if result.get("nonBackground", 0) < 1000: raise RuntimeError(f"smoke marker reported but canvas was blank: {result}") + # The marker only proves a frame was drawn and input arrived; without this + # the test would pass on the software fallback while the GPU path was + # broken, which is the one thing it cannot notice from the pixels. + renderer = result.get("renderer", "") + if renderer != expect_renderer: + raise RuntimeError( + f"the page rendered with {renderer!r}, expected {expect_renderer!r}: {result}" + ) print( "BROWSER-WASM-SMOKE PASS " f"{result.get('width')}x{result.get('height')} " + f"renderer={renderer} " f"nonBackgroundSamples={result.get('nonBackground')}" ) - break + return renderer if errors: raise RuntimeError("browser error: " + " | ".join(errors[-5:])) time.sleep(0.15) @@ -228,9 +240,15 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("site", type=Path, help="the published wwwroot directory") parser.add_argument("--timeout", type=float, default=45) + parser.add_argument( + "--renderer", + choices=["webgl2", "2d"], + default="webgl2", + help="the renderer the page must report having used (default: webgl2, the page's default)", + ) args = parser.parse_args() try: - run(args.site, args.timeout) + run(args.site, args.timeout, args.renderer) return 0 except Exception as error: print(f"BROWSER-WASM-SMOKE FAIL: {error}", file=sys.stderr)