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)