Skip to content

[web] SkSemaphore is a no-op under -sWASM_WORKERS=1, racing FT_Face #193695

Closed

Description

@kevmoo

Summary

In #190048 (fixing #190039) and #191014, skwasm enabled SK_ENABLE_THREADLOCAL_STRIKECACHE because multithreaded skwasm and wimp are compiled with -sWASM_WORKERS=1 (engine/src/build/config/compiler/BUILD.gn:312) without -pthread, which links single-threaded Emscripten system libraries where POSIX synchronization primitives (sem_wait, pthread_cond_wait) are no-op stubs.

Making SkStrikeCache thread-local did not eliminate shared mutable state between main-thread $paragraph_layout and DedicatedWorker _surface_renderPicturesOnWorker:

  • Both threads share the same SkFontMgr, FlutterFontCollection, and SkTypeface_FreeType (FT_Face / FT_Library) instances in shared WebAssembly.Memory (engine/src/flutter/skwasm/fonts.cc:38-42).
  • Whenever the main thread's thread-local SkStrike (during $paragraph_layout) and the worker thread's thread-local SkStrike (during _surface_renderPicturesOnWorker) miss at the same time on the same typeface (for example, when animating layout or FittedBox scales), both threads concurrently enter SkScalerContext_FreeType::generateMetrics / generateImage -> FT_Load_Glyph -> TT_Load_Glyph on the same non-thread-safe FreeType FT_Face.
  • In Skia (SkFontHost_FreeType.cpp), concurrent SkScalerContext_FreeType instances across threads rely on f_t_mutex() (SkMutex) and fFaceRec->fFTMutex (SkMutex) — backed by SkSemaphore (third_party/skia/src/base/SkSemaphore.cpp) — to serialize access to FT_Library and FT_Face.
  • Because SkSemaphore::osWait() (sem_wait) compiles to a no-op stub (0 blocking or spinning instructions) in both skwasm.wasm (wasm-function[268]) and wimp.wasm (wasm-function[302]), f_t_mutex().acquire() returns immediately when contended (fCount.fetch_sub(1) <= 0), allowing both threads to mutate FT_Face simultaneously.

Steps to Reproduce

  1. Build kevmoo/flutter-wasm-compare at commit 5c4e80e with flutter build web --wasm (3.49.0-1.0.pre-171, engine 1a3d11d5ab1).
  2. Serve build/web with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp so skwasm_isMultiThreaded() == true.
  3. Navigate to ?workload=grid&nodes=100&renderer=skwasm (or renderer=wimp, or nodes=1000), which animates 100 GridCell cards containing FittedBox + Text paragraphs every frame while the DedicatedWorker rasterizes the previous frame.

Observed Behavior

Across repeated runs on both macOS (Apple M4 Pro, Chrome 146) and Linux (x86_64, Headless Chrome 154), multi-threaded grid@100 and grid@1000 intermittently fail in one of two ways (0 failures in single-threaded skwasm or wimp across 100+ runs):

  1. Uncaught RuntimeError: memory access out of bounds in $paragraph_layout -> FT_Load_Glyph -> TT_Load_Simple_Glyph:
    Instrumenting worker postMessage (cbId) and Runtime.exceptionThrown via CDP shows that while the DedicatedWorker is mid-flight executing _surface_renderPicturesOnWorker (cbId = 23, dispatched at t = 1721.6 ms, completed at t = 1753.1 ms), the main UI thread throws at t = 1731.0 ms:
RuntimeError: memory access out of bounds
    at skwasm.wasm:wasm-function[2107]:0x63e5b   // TT_Load_Simple_Glyph (FreeType ttgload.c)
    at skwasm.wasm:wasm-function[10931]:0x2863fd // load_truetype_glyph (FreeType ttgload.c)
    at skwasm.wasm:wasm-function[10942]:0x28832c // TT_Load_Glyph (FreeType ttgload.c)
    at skwasm.wasm:wasm-function[721]:0x1f3c1    // FT_Load_Glyph (FreeType ftobjs.c)
    at skwasm.wasm:wasm-function[11471]:0x2a7ee9 // SkScalerContext_FreeType::generateMetrics (calls f_t_mutex() func 452 -> func 268)
    at skwasm.wasm:wasm-function[6734]:0x185fda  // SkScalerContext::makeGlyph
    at skwasm.wasm:wasm-function[1776]:0x51f44   // SkStrike::digestFor
    at skwasm.wasm:wasm-function[2783]:0x8e456   // SkBulkGlyphMetrics::glyph
    at skwasm.wasm:wasm-function[6681]:0x182bb3  // SkBulkGlyphMetrics::glyphs
    at skwasm.wasm:wasm-function[2246]:0x6b813   // SkStrike::lock / glyph metrics
    ...
    at skwasm.wasm:wasm-function[9227]:0x227493  // skia::textlayout::ParagraphImpl::layout
    at $paragraph_layout (skwasm.wasm:0x26ee84)  // engine/src/flutter/skwasm/text/paragraph.cc:55

This uncaught trap aborts PipelineOwner.flushLayout() mid-frame and stops all subsequent scene production while requestAnimationFrame continues firing.

  1. Main-thread infinite busy-spin in dlmalloc:
    If a thread traps while holding Emscripten's dlmalloc spinlock (wasm-function[276] / wasm-function[222], spinning on i32.atomic.rmw.xchg offset=603928) or concurrent FreeType FT_Memory allocations corrupt allocator state, the next main-thread allocation spins indefinitely in dlmalloc, hanging the tab.

Disassembling wasm-function[268] in skwasm.wasm (and identical wasm-function[302] in wimp.wasm) confirms that SkSemaphore::wait() returns immediately without waiting when fCount.fetch_sub(1) <= 0:

(func (;268;) (type 2) (param i32)
  block ;; label = @1
    block ;; label = @2
      local.get 0
      i32.const 1
      i32.atomic.rmw.sub
      i32.const 0
      i32.gt_s
      br_if 0 (;@2;) ;; Uncontended (old fCount > 0): lock acquired, return.
      block ;; label = @3
        block ;; label = @4
          local.get 0
          i32.atomic.load8_u offset=4 ;; SkOnce state (0=NotStarted, 1=Claimed, 2=Done)
          br_table 0 (;@4;) 1 (;@3;) 2 (;@2;) 1 (;@3;)
          ;; When SkOnce == 2 (Done), jumps directly to @2 (return) with zero wait instructions.
        end
        local.get 0
        i32.const 0
        i32.const 1
        i32.atomic.rmw8.cmpxchg_u offset=4
        i32.eqz
        br_if 2 (;@1;)
      end
      loop ;; label = @3
        local.get 0
        i32.atomic.load8_u offset=4
        i32.const 2
        i32.ne
        br_if 0 (;@3;)
      end
    end
    return
  end
  local.get 0
  i32.const 16
  call 224 ;; operator new(16) for OSSemaphore (sem_t)
  i32.store offset=8
  local.get 0
  i32.const 2
  i32.atomic.store8 offset=4
)

Expected Behavior

SkMutex / SkSemaphore (third_party/skia/src/base/SkSemaphore.cpp) must enforce mutual exclusion when compiled with -sWASM_WORKERS=1 (e.g., via Wasm atomics / spin-wait on the main browser thread and emscripten_futex_wait on workers), or SkTypeface_FreeType (FT_Face) must not be shared across threads without synchronization.

Environment / Target

Activity

  1. added
    c: crashStack traces logged to the console
    engineflutter/engine related. See also e: labels.
    on Oct 2, 2026
  2. added
    platform-webWeb applications specifically
    e: wasmIssues related to the wasm build of Flutter Web.
    e: web_skwasmSkwasm rendering backend for web
    team-webOwned by Web platform team
    on Oct 2, 2026
  3. kevmoo commented on Oct 16, 2026

    @kevmoo
    ContributorAuthor

    #194139 (-Wl,--wrap=sem_*) is required for both skwasm and wimp multi-threaded builds—neither SkTypeface::makeClone nor emscripten_futex_wait can replace it:

    1. Why -Wl,--wrap=sem_* ([skwasm] Implement Wasm worker futex semaphores #194139) is required even if typefaces are cloned:
      In SkFontHost_FreeType.cpp, f_t_mutex() is a single process-wide SkMutex protecting both gFTLibrary and every SkScalerContext_FreeType method (setupSize at L1130, generateMetrics at L1221, generateImage at L1400, and generateFontMetrics at L1565), while SkTypeface_FreeType::fC2GCacheMutex (SkTypeface_FreeType.h:87) is an SkSharedMutex backed by SkSemaphore (SkSharedMutex.cpp:281). Moving glyph rasterization onto the UI thread in WIMP would also pull FreeType outline rasterization back onto the UI frame budget and leave skwasm's Ganesh worker path (render_context_skia.cc:39-49) unprotected.

    2. Why emscripten_futex_wait / emscripten_futex_wake cannot be used under -sWASM_WORKERS=1:
      In Emscripten's tools/system_libs.py (libc.get_files()), -sWASM_WORKERS=1 without -pthread links system/lib/pthread/library_pthread_stub.c into libc-ww.a, where emscripten_futex_wait and emscripten_futex_wake (lines 24–33) are stubbed to return 0 just like sem_init / sem_wait / sem_post (lines 359–377). Additionally, the WebAssembly Threads spec forbids memory.atomic.wait32 (Atomics.wait) on the main browser thread ([[CanBlock]] == false) regardless of timeout, so spinning on worker ID 0 while calling emscripten_atomic_wait_u32 on worker threads in [skwasm] Implement Wasm worker futex semaphores #194139 is the exact required behavior (matching Emscripten's own futex_wait_main_browser_thread in emscripten_futex_wait.c).

    3. Cached thread_local worker check in [skwasm] Implement Wasm worker futex semaphores #194139 & upstream Skia follow-up:
      emscripten_wasm_worker_self_id() is a JS FFI import (() => Module['$ww'] in src/library_wasm_worker.js:247); [skwasm] Implement Wasm worker futex semaphores #194139 now caches static thread_local const bool can_block = emscripten_wasm_worker_self_id() != 0; inside __wrap_sem_wait to avoid a Wasm-to-JS call on contended waits. Upstreaming a native #elif defined(__EMSCRIPTEN_WASM_WORKERS__) branch into SkSemaphore.cpp can follow in Skia so osSignal(int n) can also wake n waiters in a single emscripten_atomic_notify(count, n) call.

  4. github-actions commented on Oct 31, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  5. locked as resolved and limited conversation to collaborators on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

c: crashStack traces logged to the consolee: wasmIssues related to the wasm build of Flutter Web.e: web_skwasmSkwasm rendering backend for webengineflutter/engine related. See also e: labels.platform-webWeb applications specificallyteam-webOwned by Web platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions