Skip to content

Commit fa39846

Browse files
authored
Merge pull request #93 from vchelaru/fix/90-touch-ui-starvation
Fix touch toolbar unresponsive after Run (issue #90)
2 parents 8d85e7d + fbf2f7b commit fa39846

7 files changed

Lines changed: 351 additions & 168 deletions

File tree

.claude/skills/game-lifecycle/SKILL.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: The compile-to-run pipeline and WebGL/GraphicsDevice resource lifecycle in XnaFiddle — how a Run rebuilds the game, the per-run WebGL context leak and its UseReferenceDevice fix, Roslyn metadata-reference caching, and documented dead-ends. Load when working on DoCompileAndRun, game restart/lifecycle, GraphicsDevice/WebGL/canvas issues, render targets / mipmapped RenderTarget2D / explicit-LOD (SampleLevel) sampling / multi-texture effects, "Shader Compilation Failed"/CONTEXT_LOST_WEBGL/"Too many active WebGL contexts"/"texParameter: no texture bound" crashes, compile performance, or CompilationService.
2+
description: The compile-to-run pipeline and WebGL/GraphicsDevice resource lifecycle in XnaFiddle — how a Run rebuilds the game, the per-run WebGL context leak and its UseReferenceDevice fix, Roslyn metadata-reference caching, and documented dead-ends. Load when working on DoCompileAndRun, game restart/lifecycle, GraphicsDevice/WebGL/canvas issues, mobile restart crashes / touch UI starvation (issue #90 — Window.Current event leak, touch-toolbar bypass), diagnosing console-less mobile WASM crashes (Mono aborts, Debug.Assert vs native assertions), render targets / mipmapped RenderTarget2D / explicit-LOD (SampleLevel) sampling / multi-texture effects, "Shader Compilation Failed"/CONTEXT_LOST_WEBGL/"Too many active WebGL contexts"/"texParameter: no texture bound" crashes, compile performance, or CompilationService.
33
---
44

55
# Game Lifecycle
@@ -72,6 +72,32 @@ Reusing the same `MetadataReference` instances also lets Roslyn reuse decoded sy
7272

7373
Warm result: reference resolution ~85ms (all cached), `Emit` ~600ms — **emit now dominates** (the cache turned the reference step from the bottleneck into a rounding error). The user-facing "Compiled in Xs" message in `DoCompileAndRun` reflects the total.
7474

75+
## Touch UI starvation (issue #90)
76+
77+
Blazor WASM is single-threaded. `index.html` `tickJS` calls `TickDotNet` synchronously on every rAF frame; uncapped FPS after Run can starve Blazor `@onclick` on touch devices (toolbar buttons stop responding). **Fix:** capture-phase `touchend``invokeMethodAsync` on toolbar buttons (`data-touch-action`). Desktop uses normal `@onclick`.
78+
79+
**Restart memory (mobile):** WASM cannot unload assemblies. Each unchanged Restart used to `Assembly.Load` again until mobile OOM'd (~3rd run). **Fix:** `CompileFingerprint` (C# + shader tabs) caches the loaded `Type`; unchanged restarts skip Roslyn + `Assembly.Load` and only tear down + `Activator.CreateInstance` the cached type. Desktop tolerated redundant loads; mobile did not.
80+
81+
### The mobile restart CRASH — two distinct bugs (both fixed)
82+
83+
The starvation/memory fixes above did **not** stop the crash. It was finally root-caused to two unrelated bugs; FPS throttling and the WebGL-context-leak hypothesis were both wrong (see dead ends).
84+
85+
**Bug 1 — `Window.Current` input-event subscription leak (the primary fix).** `nkast.Wasm.Dom.Window.Current` is a page-lifetime singleton; its input events are plain **public multicast-delegate fields** (`Wasm.Dom/Dom/Window.cs` — `OnResize/OnFocus/OnBlur`, `OnMouse*`, `OnKey*`, `OnTouchStart/Move/End/Cancel`, `OnGamepad*`). Every game's `BlazorGameWindow` ctor does `_window.OnTouchStart += closure` (etc.) with `_window = Window.Current` (`BlazorGameWindow.cs`). Old games are dropped without `Dispose()` (intentional — see "Each Run rebuilds"), so these closures **accumulate one set per Run** and are never removed. A single touch fires the whole multicast delegate (`Window.cs` `JsWindowOnTouchStart` → `handler(...)`); stale closures from dead games reach into a torn-down `TouchPanel.Current` strategy and trip a **native Mono runtime assertion** (`mono/metadata/class-accessors.c:92`) → `abort()` → `exit(1)` → dead app. Mobile-only (touch path; desktop mouse tolerates 50+ restarts); scales with restart count (died ~2nd-3rd restart). **Fix:** `GameWindowPlugin.CleanUp()` now nulls every public delegate field on `Window.Current` by reflection (in addition to its existing `_instances` / `Document._elementsCache` clears). `CleanUp` runs before the next game's ctor re-subscribes → a single subscriber per Run.
86+
87+
**Bug 2 — touch-toolbar bypass swallowed `touchend`, desyncing KNI's TouchPanel (DEBUG-only).** The original #90 bypass intercepted only capture-phase `touchend` on `[data-touch-action]` buttons and called `stopImmediatePropagation()`. But `touchstart` still reached KNI's window listener → `AddPressedEvent(id)` registered the press; the swallowed `touchend` meant `AddReleasedEvent(id)` never fired → a dangling `nativeTouchId`. The next tap reusing that id hit `TouchPanelStrategy.AddPressedEvent`'s `Debug.Assert("nativeTouchId already registered")` (`ConcreteTouchPanel.cs` / `TouchPanelStrategy.Legacy*.cs`). **`Debug.Assert` is `[Conditional("DEBUG")]` → stripped in Release**, so this crash is **dev-server only** (`dotnet run`); a published build would leave a harmless phantom stuck touch instead. **Fix:** `wireTouchToolbar` now stops `touchstart`/`touchmove`/`touchend`/`touchcancel` for `[data-touch-action]` targets (only `touchend` triggers the action) → toolbar taps are fully invisible to KNI, press/release stay balanced. (Touch events keep their initial target across the whole sequence, so `closest('[data-touch-action]')` matches every phase.)
88+
89+
## Diagnosing mobile WASM crashes (no console)
90+
91+
The playbook that cracked #90 — mobile has no usable console:
92+
93+
- **`Console.WriteLine` is invisible.** Build a **pure-JS** on-screen ring-buffer panel that writes to the DOM directly (not via Blazor) so it survives a dead Blazor circuit (the failure mode kills the circuit).
94+
- **Mono/Emscripten aborts throw a plain object / `ExitStatus` / number**, not an `Error``e.message` stringifies to `[object Object]`. Unwrap `name`/`message`/`status`/`stack` for the real reason.
95+
- The real abort reason usually prints via `console.error` **before** the `ExitStatus` throw → tee `console.warn`/`console.error` into the panel; `window.onerror`/`unhandledrejection` alone miss it.
96+
- **WebGL context loss is observable without a debugger** via **capture-phase** `webglcontextlost`/`webglcontextrestored`/`webglcontextcreationerror` listeners on `window` (these events don't bubble; `webglcontextcreationerror.statusMessage` carries "Too many active WebGL contexts").
97+
- **Distinguish crash classes:** a **native Mono assertion** (`class-accessors.c`, `loader.c`, …) fires in Release too — a real runtime-invariant violation. A managed **`Debug.Assert` is DEBUG-only** (stripped in Release) — check the build config before treating a dev-server crash as production-affecting.
98+
- **Stale-cache verification needs a *deterministic* build marker** (fixed value bumped per edit), not a random one — random only proves the RNG ran, not which build is served. No service worker here; staleness is plain HTTP browser cache. (The in-app ForceRefresh "Refresh" button also throws over plain HTTP — `caches`/CacheStorage requires a secure context.)
99+
- **`chrome://inspect` gotcha:** do **not** run a standalone `adb` server alongside Chrome's bundled ADB — they fight over the device and `inspect` hangs / device shows "Offline (pending authentication)". Chrome's ADB uses its own RSA key, so authorizing standalone adb doesn't authorize Chrome's.
100+
75101
## Why the leak "suddenly appeared"
76102

77103
It is pre-existing in KNI and independent of any XnaFiddle change. It surfaced only after the metadata-reference cache made compiles fast (~0.7s vs several seconds): fast iteration means a user naturally does 10+ Runs in one page session before refreshing, which is what reaches the context cap. It is **not** a GC-churn regression (see dead end #1).
@@ -86,15 +112,19 @@ It is pre-existing in KNI and independent of any XnaFiddle change. It surfaced o
86112
1. **`GC.Collect()` / `GC.WaitForPendingFinalizers()` to reclaim leaked GL resources.** Useless: the leaked WebGL contexts are JS-side objects pinned in the `nkJSObject` registry, not .NET objects — GC can't touch them. (KNI's `GraphicsResource` finalizer does delete GL handles, but that was never the leak.)
87113
2. **Recreating `theCanvas` *every run* to fix the leak.** Pointless: `theCanvas` was never the leak (the OffscreenCanvas probe is — use `UseReferenceDevice`). Recreating it per run only adds the swap's failure modes. (Recreating it *on a profile switch* is correct — see that section — but only because two specific things are handled: clear `Document._elementsCache` so the stale `Canvas` wrapper isn't reused (else black screen), and recreate via Blazor `@key` rather than raw JS removal (else Blazor DOM-diff desync). Doing a raw JS swap without those two is the dead end.)
88114
3. **`WEBGL_lose_context.loseContext()` on the old context.** In Chrome this can reset the whole GPU process; a context created in the same synchronous turn comes up already-lost -> `CONTEXT_LOST_WEBGL` on the new device's first GL call.
115+
4. **FPS throttling to fix the #90 restart crash.** The 10/20fps full-editor cap tried during #90 did **not** fix the crash and was unrelated to frame rate — removed. The toolbar-unresponsive symptom is fixed by the `touchend` bypass, not a frame cap. (Embed-mode 20fps mobile / 30fps desktop is a separate, intentional, pre-existing cap — keep it.)
116+
5. **Assuming every mobile restart crash is the WebGL context leak.** It was the leading #90 hypothesis but was **disproven** — no `webglcontext*` events fired. The actual #90 crash was the `Window.Current` subscription leak. The context-exhaustion crash is real but is a *different* bug, already covered by the `UseReferenceDevice` fix above.
89117

90118
## Key files
91119

92120
| File | Role |
93121
|---|---|
94122
| `XnaFiddle.BlazorGL/Pages/Index.razor.cs` | `DoCompileAndRun`, `CompileAndRun`, `TickDotNet`, swap window, `UseReferenceDevice` fix, `_canvasProfile`/`PromptProfileSwitch` |
123+
| `XnaFiddle.BlazorGL/wwwroot/index.html` | `tickJS` rAF loop, `_tickInterval` FPS cap (embed only: 20fps mobile / 30fps desktop), `wireTouchToolbar` — generalized touch bypass swallowing all 4 touch phases (issue #90) |
95124
| `XnaFiddle.BlazorGL/CompilationService.cs` | Roslyn compile, `_referenceCache`, `GetMetadataReferencesAsync`, `LogTiming` |
96125
| `XnaFiddle.Core/LibraryRegistry.cs` | `RunAllCleanups` — per-run plugin static-state reset |
97-
| `XnaFiddle.Core/Plugins/GameWindowPlugin.cs` | Clears KNI's static `BlazorGameWindow._instances` by reflection |
126+
| `XnaFiddle.Core/Plugins/GameWindowPlugin.cs` | Reflectively clears KNI's `BlazorGameWindow._instances`, `Document._elementsCache`, **and** every public delegate field on `Window.Current` (issue #90) |
127+
| `Submodules/KniSB/Submodules/WasmSB/Wasm.Dom/Dom/Window.cs` | `Window.Current` singleton; public multicast input-event delegate fields; `JsWindowOnTouchStart` fans a touch to all subscribers |
98128
| `Submodules/KniSB/Platforms/Graphics/.BlazorGL/ConcreteGraphicsAdapter.cs` | `Platform_IsProfileSupported` — the leaking HiDef probe + the `UseReferenceDevice` short-circuit |
99129
| `Submodules/KniSB/.../Wasm.Canvas/Canvas/OffscreenCanvas.cs` | Probe's WebGL2 context creation (no-attribs path leaks) |
100-
| `Submodules/KniSB/Platforms/Game/.Blazor/BlazorGameWindow.cs` | Resolves `theCanvas`; static `_instances` dictionary |
130+
| `Submodules/KniSB/Platforms/Game/.Blazor/BlazorGameWindow.cs` | Resolves `theCanvas`; static `_instances` dictionary; ctor subscribes per-game closures to `Window.Current` events, never unsubscribed (no Dispose) |

XnaFiddle.BlazorGL/Pages/Index.razor

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,29 +30,29 @@
3030
<div style="padding: 6px 12px; background: #2d2d2d; display: flex; align-items: center; gap: 8px; flex-shrink: 0;">
3131
@if (_isCompiling)
3232
{
33-
<button @onclick="StopCompilation"
33+
<button data-touch-action="TriggerStopCompilation" @onclick="StopCompilation"
3434
style="padding: 4px 14px; background: #e04040; color: #fff; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
3535
<i class="codicon codicon-debug-stop"></i>
3636
Stop
3737
</button>
3838
}
3939
else
4040
{
41-
<button @onclick="CompileAndRun"
41+
<button data-touch-action="TriggerCompileAndRun" @onclick="CompileAndRun"
4242
style="padding: 4px 14px; background: #0e639c; color: white; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
4343
<i class="codicon codicon-play"></i>
4444
@(_game != null ? "Restart" : "Run")
4545
</button>
4646
@if (_game != null)
4747
{
48-
<button @onclick="StopGame"
48+
<button data-touch-action="TriggerStopGame" @onclick="StopGame"
4949
style="padding: 4px 14px; background: #e04040; color: #fff; border: none; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;">
5050
<i class="codicon codicon-debug-stop"></i>
5151
Stop
5252
</button>
5353
}
5454
}
55-
<button @onclick="OpenExampleBrowser"
55+
<button data-touch-action="TriggerOpenExampleBrowser" @onclick="OpenExampleBrowser"
5656
style="padding: 4px 8px; background: @(_exampleBrowserOpen ? "#0e639c" : "#3c3c3c"); color: #d4d4d4;
5757
border: 1px solid #555; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 5px;
5858
user-select: none; -webkit-user-select: none;">
@@ -789,7 +789,7 @@
789789
Pick an example with the Examples button, or write your own XNA-style C# code,<br/>
790790
then click the Run button to compile and run.
791791
<div style="margin-top: 16px; pointer-events: auto;">
792-
<button @onclick="CompileAndRun"
792+
<button data-touch-action="TriggerCompileAndRun" @onclick="CompileAndRun"
793793
style="padding: 6px 18px; background: #0e639c; color: white; border: none; cursor: pointer; font-size: 14px; display: inline-flex; align-items: center; gap: 6px;">
794794
<i class="codicon codicon-play"></i>
795795
Run

0 commit comments

Comments
 (0)