Skip to content

[web] Multi-threaded skwasm hangs or crashes in FreeType: SkMutex is a no-op under -sWASM_WORKERS, so main-thread text layout and the raster worker share one FT_Face #193439

Closed

Description

@AurelVU

Steps to reproduce

With multi-threaded skwasm (flutter build web --wasm, page served cross-origin isolated so skwasm runs its raster worker), a web app freezes for good -- or crashes with RuntimeError: memory access out of bounds inside FreeType -- when the main thread lays out text while the raster worker draws glyphs of the same font.

We hit it in production (a design-editor app: the canvas stops responding, pointer and cursors still alive), and reduced it to a stock flutter create app plus one file:

  1. flutter create skwasm_race && cd skwasm_race
  2. Put any TTF into assets/montserrat.ttf (reproduced with variable Montserrat and with static Roboto from Google Fonts) and list it under flutter: assets: in pubspec.yaml.
  3. Replace lib/main.dart with the code below.
  4. flutter build web --wasm --release
  5. Serve build/web with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp (script below), open it in Chrome and check crossOriginIsolated === true.

The page draws one paragraph at six continuously changing fractional scales (the raster worker keeps rasterising glyphs at new ppems), while a main-thread timer keeps laying out paragraphs of the same font at random sizes (SkParagraph shaping -> glyph metrics on the same FT_Face).

Controls: ?layout=0 (no main-thread layout, worker alone) runs indefinitely; ?zoom=0 (main thread alone) runs indefinitely. Only the two together hang.

Expected results

The page keeps animating indefinitely.

Actual results

Within seconds the tab stops responding: both the main thread and the raster worker spin (renderer ~180 % CPU, DevTools Runtime.evaluate times out). Profiling the worker shows it at 100 % in FreeType af_glyph_hints_reload (names from skwasm.js.symbols). Other runs of the same page end in RuntimeError: memory access out of bounds in FT_Done_Size / FT_List_Find.

Cause (as far as we can tell)

skwasm links with -sWASM_WORKERS=1 but without -pthread, so Emscripten's pthread stubs (system/lib/pthread/library_pthread_stub.c) are linked in and sem_wait() / sem_post() return immediately. The optimizer folds SkSemaphore::osWait / osSignal away, so SkSemaphore -- and therefore every SkMutex -- never blocks. That includes the FreeType face mutex in SkFontHost_FreeType.cpp (f_t_mutex()) and the strike cache lock. The main thread (paragraph layout) and the raster worker (glyph drawing) then use the same FT_Face concurrently, while FreeType says "An FT_Face object can only be safely used from one thread at a time."

#178749 describes the same no-op mutex problem for the strike cache in the wimp (Impeller web) work. This is the stock skwasm renderer shipping in stable today, and the thread-local strike cache work (#190048 / #191014) does not cover the FreeType face lock.

As a workaround we binary-patch the shipped skwasm.wasm: SkSemaphore::wait() / signal(int) are replaced with a spinning counting semaphore on the OSSemaphore* word. It spins rather than using memory.atomic.wait because the browser main thread may not block. With that patch the repro and our production app run without hangs. Single-threaded skwasm or CanvasKit avoid it too, but were several times slower for us (edit latency p90 36 ms -> 178 ms).

Code sample

lib/main.dart
// Minimal repro: skwasm (multi-threaded) freezes or crashes when the main
// thread lays out text while the raster worker draws the same font.
//
// One font (fetched, registered with loadFontFromList). The canvas draws a
// block of text under a continuously changing zoom (the raster worker
// rasterises glyph images at new sizes: FT_Load_Glyph -> autofitter), while
// the main thread keeps laying out paragraphs of the same font at new sizes
// (SkParagraph shaping -> glyph metrics: FT_Load_Glyph -> autofitter on the
// same FT_Face).
//
// Query parameters:
//   font=montserrat|roboto   bundled font (default montserrat)
//   layout=0        no main-thread layout (control: worker alone)
//   zoom=0          no zooming (control: main thread alone)
//   wght=<n>        fontVariations wght (default 400; 0 = none)
//   burst=<ms> period=<ms> start=<s>   main-thread layout duty cycle
//
// Probe from JS: window.__race = {builds, rasters, layouts, lastRaster}.
import 'dart:async';
import 'dart:js_interop';
import 'dart:math' as math;
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';

@JS('globalThis')
external JSObject get _global;

extension on JSObject {
  external set __race(JSAny value);
}

const _text =
    'Оформление заказа Доставка курьером Итого к оплате 12 345,67 ₽ '
    'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ абвгдеёжзийклмнопрстуфхцчшщъыьэюя '
    'ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 '
    '()*+,-.:@«»|•';

int builds = 0, rasters = 0, layouts = 0;
double lastRasterMs = 0;

void _publish() {
  _global.__race = {
    'builds': builds,
    'rasters': rasters,
    'layouts': layouts,
    'lastRaster': lastRasterMs,
  }.jsify()!;
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final q = Uri.base.queryParameters;
  final font = q['font'] ?? 'montserrat';
  final data = await rootBundle.load('assets/$font.ttf');
  await ui.loadFontFromList(data.buffer.asUint8List(), fontFamily: 'RaceFont');
  final wght = double.tryParse(q['wght'] ?? '400') ?? 400;
  final variations = wght > 0 ? [ui.FontVariation('wght', wght)] : null;
  final doLayout = q['layout'] != '0';
  final doZoom = q['zoom'] != '0';


  SchedulerBinding.instance.addTimingsCallback((timings) {
    rasters += timings.length;
    lastRasterMs = DateTime.now().millisecondsSinceEpoch.toDouble();
    _publish();
  });

  if (doLayout) {
    // Keep the main thread in SkParagraph/FreeType as much as possible,
    // with a fresh size (a fresh strike) every paragraph.
    final rnd = math.Random(1);
    final burst = int.tryParse(q['burst'] ?? '6') ?? 6;
    final period = int.tryParse(q['period'] ?? '1') ?? 1;
    final start = DateTime.now().add(
      Duration(seconds: int.tryParse(q['start'] ?? '0') ?? 0),
    );
    Timer.periodic(Duration(milliseconds: period), (_) {
      if (DateTime.now().isBefore(start)) return;
      final end = DateTime.now().add(Duration(milliseconds: burst));
      while (DateTime.now().isBefore(end)) {
        final size = 4 + rnd.nextDouble() * 60;
        final b = ui.ParagraphBuilder(ui.ParagraphStyle())
          ..pushStyle(
            ui.TextStyle(
              fontFamily: 'RaceFont',
              fontSize: size,
              fontVariations: variations,
            ),
          )
          ..addText(_text);
        b.build()
          ..layout(const ui.ParagraphConstraints(width: 400))
          ..dispose();
        layouts++;
      }
      _publish();
    });
  }

  runApp(_App(variations: variations, zoom: doZoom));
}

class _App extends StatefulWidget {
  const _App({required this.variations, required this.zoom});
  final List<ui.FontVariation>? variations;
  final bool zoom;
  @override
  State<_App> createState() => _AppState();
}

class _AppState extends State<_App> with SingleTickerProviderStateMixin {
  late final Ticker _ticker;
  double _t = 0;

  @override
  void initState() {
    super.initState();
    _ticker = createTicker((d) {
      builds++;
      setState(() => _t = d.inMicroseconds / 1e6);
    })..start();
  }

  @override
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Directionality(
    textDirection: TextDirection.ltr,
    child: CustomPaint(
      painter: _Painter(widget.zoom ? _t : 0, widget.variations),
      size: Size.infinite,
    ),
  );
}

class _Painter extends CustomPainter {
  _Painter(this.t, this.variations);
  final double t;
  final List<ui.FontVariation>? variations;
  static ui.Paragraph? _p;

  @override
  void paint(Canvas canvas, Size size) {
    final p = _p ??= (ui.ParagraphBuilder(ui.ParagraphStyle())
          ..pushStyle(
            ui.TextStyle(
              fontFamily: 'RaceFont',
              fontSize: 12,
              color: const Color(0xFF000000),
              fontVariations: variations,
            ),
          )
          ..addText(_text))
        .build()
      ..layout(const ui.ParagraphConstraints(width: 300));
    canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.src);
    // Several copies at different, continuously changing, odd fractional
    // zooms: every frame asks the worker for glyph images at new ppems
    // (up to the atlas limit and past it).
    for (var i = 0; i < 6; i++) {
      final z = 0.8 + 12 * (0.5 + 0.5 * math.sin(t * (0.37 + i * 0.11) + i));
      canvas
        ..save()
        ..translate(10.0 + i * 3, 10.0 + i * 40)
        ..scale(z * (1 + i * 0.0137))
        ..drawParagraph(p, Offset.zero)
        ..restore();
    }
  }

  @override
  bool shouldRepaint(_Painter old) => old.t != t;
}
serve_isolated.py (cross-origin isolated static server)
"""Serves a directory with cross-origin isolation (COOP/COEP), no caching."""
import sys, os, http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Cross-Origin-Opener-Policy','same-origin')
        self.send_header('Cross-Origin-Embedder-Policy','require-corp')
        self.send_header('Cross-Origin-Resource-Policy','cross-origin')
        self.send_header('Cache-Control','no-store')
        super().end_headers()
    def log_message(self,*a): pass
port=int(sys.argv[1]); d=sys.argv[2]
http.server.ThreadingHTTPServer(('127.0.0.1',port), functools.partial(H,directory=d)).serve_forever()

Usage: python3 serve_isolated.py 8791 build/web, then open http://127.0.0.1:8791/

Flutter Doctor output

Flutter 3.44.8 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 058e0af2c2 • 2026-07-23 10:56:21 -0700
Engine • hash 13ffd72b2f9a5ca4db2a74ea52d5353ec2e8f939 (revision 0cd610717b) • 2026-07-23 16:11:34.000Z
Tools • Dart 3.12.2 • DevTools 2.57.0

Browser: Chrome 153.0.8010.53, macOS 26.3 (arm64). The skwasm.wasm is byte-identical to the one flutter build web --wasm ships with this engine.

Activity

  1. added
    in triagePresently being triaged by the triage team
    c: crashStack traces logged to the console
    engineflutter/engine related. See also e: labels.
    a: typographyText rendering, possibly libtxt
    platform-webWeb applications specifically
    e: web_skwasmSkwasm rendering backend for web
    team-webOwned by Web platform team
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Sep 28, 2026
  2. mbcorona commented on Sep 28, 2026

    @mbcorona
    Member

    Hi @AurelVU, thanks for the detailed report.

    Same failure mode as #190039, fixed in #190048 (thread local strike caches). That fix is on master, not in your 3.44.8 build, so this repro cannot tell the FreeType face lock race apart from the strike cache race #190048 already fixes.

    Could you retest on latest master? If it still crashes, it is the separate face lock issue and we route it. If not, it was #190039.

  3. flashback19 commented on Oct 2, 2026

    @flashback19

    Retested on 3.49.0-0.2.pre. It still hangs/crashes for me, so #190048 doesn't seem to be enough for this repro.

    Setup: I followed the steps above literally (flutter create, the lib/main.dart from this issue unchanged, flutter build web --wasm --release, served with the serve_isolated.py from the issue). I only had a static Roboto TTF at hand, so it is in both assets/montserrat.ttf and assets/roboto.ttf (no variable Montserrat).

    Flutter 3.49.0-0.2.pre, framework 38ec981, engine 774a767, Dart 3.14.0. That engine contains #190048 and #191014 (checked with the GitHub compare API). I did not try a master tip build.
    Browser: headless Chrome for Testing 153.0.8010.12 (Playwright), Linux VM, software GL (SwiftShader). One fresh browser per run, 25 s observation after load. crossOriginIsolated was true wherever the page still answered.

    Results:

    • isolated, default query: 5/5 failed (3 hang, 2 crash)
    • isolated, ?font=roboto: 3/3 failed (2 hang, 1 crash)
    • isolated, ?layout=0: 3/3 OK
    • isolated, ?zoom=0: 3/3 failed
    • isolated, ?zoom=0&start=6: 3/3 OK
    • same build served without COOP/COEP (single-threaded skwasm): 5/5 OK

    The crashes show up as RuntimeError: function signature mismatch, operation does not support unaligned accesses and table index is out of bounds.

    Two differences from the report: the failure happens right at startup for me (rasters never get past a few frames) rather than after some seconds of animation, and ?zoom=0 is not a clean control here. It fails too, probably because the worker is still rasterizing the first frame when the layout timer starts. With start=6 (layout begins after the first frame) it runs fine, so worker alone and main thread alone are both OK and only the overlap breaks it.

    Earlier runs of the same app with a different server setup (COEP credentialless, and Document-Isolation-Policy) gave 12/12 failures on both 3.44.8 and 3.49.0-0.2.pre and 0/6 without isolation.

    So this looks like the separate FT_Face / no-op SkSemaphore problem (#193695 describes the same thing) rather than the strike cache race.

  4. github-actions commented on Oct 19, 2026

    @github-actions

    Without additional information, we are unfortunately not sure how to resolve this issue. We are therefore reluctantly going to close this bug for now.

    If you find this problem please file a new issue with the same description, what happens, logs and the output of 'flutter doctor -v'. All system setups can be slightly different so it's always better to open new issues and reference the related ones.

    Thanks for your contribution.

  5. github-actions commented on Nov 2, 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    a: typographyText rendering, possibly libtxtc: crashStack traces logged to the consolee: web_skwasmSkwasm rendering backend for webengineflutter/engine related. See also e: labels.platform-webWeb applications specificallyteam-webOwned by Web platform teamwaiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions