Skip to content

[web][skwasm] Wasm heap corruption under concurrent text layout and rasterization with multi-threaded skwasm; page then spins at 100% CPU #190039

Closed

Description

@ColeSpringer

Steps to reproduce

  1. flutter create repro, replace lib/main.dart with the sample below
  2. flutter build web --wasm
  3. Serve build/web cross-origin isolated so skwasm picks its
    multi-threaded mode. I used the node script below, which sends
    Cross-Origin-Opener-Policy: same-origin and
    Cross-Origin-Embedder-Policy: credentialless
  4. Open http://127.0.0.1:4499/ in Chromium, ideally two or three tabs

The app is deliberately abusive: it rebuilds a few dozen multi-span
paragraphs with jittered styles on every frame inside a rotating
transform, so the UI thread is laying out text while the render worker
rasterizes the previous frame, continuously.

Expected results

The animation keeps running. Dropped frames are fine, the workload is heavy on purpose.

Actual results

Each tab dies within seconds (3 to 30 in my runs, usually under 10).
Two presentations of what looks like the same underlying corruption:

  • the console gets a wasm RuntimeError from skwasm.wasm (memory access out of bounds, or operation does not support unaligned accesses from an atomic op), and the page freezes, or
  • the page just freezes with nothing in the console at all (I believe
    the fault landed on the render worker that time, where nothing
    surfaces as a pageerror)

After the freeze the renderer's main thread and its DedicatedWorker
thread (the skwasm render thread) sit at 100% CPU each, indefinitely.
The page never executes JS again: requestAnimationFrame stops,
evaluate from DevTools never returns, even CDP Page.captureScreenshot
stalls. It is not a tab crash and memory is not exhausted.

Where I first hit this: a self-hosted media server whose web UI is a
perfectly ordinary Flutter app. Our Playwright e2e suite froze this way
about once per four runs (53 tests, whichever spec happened to be
mid-step), which is what sent me digging. The sample below just
concentrates the same load to make it reproduce in seconds instead of
once an hour.

Two controls that I think localize it:

  • Same build, same workload, forceSingleThreadedSkwasm: true: clean
    for a full 8 minute run of the hammer sample, and ten consecutive
    runs of our real e2e suite with no freeze (multi-threaded baseline
    was roughly 1 freeze in 4 runs).
  • Tab count does not change per-tab incidence, so it is a race inside
    one page's two threads, not cross-tab contention.

The captured fault stacks (symbolized with the names in
canvaskit/skwasm.js.symbols) all run through SkStrike, the shared
glyph cache, reached from the layout side on one thread and the
glyph-drawing side on the other, which is where I would start looking.
Full stacks in Logs below.

Related issues: #184858 looks like the same corruption seen from the
dispose side (paragraphBuilder_dispose / textStyle_dispose during
heavy rebuilds); it was closed unreproduced and the close message asks
for a new issue with a runnable reproduction, which is what this is.
The allocation-side analysis davidmigloz posted there matches what I
see. The stack corruption fix from #182912 predates the engine I am on
(3.44.6 stable), so this either survived that fix or is a different
defect.

Code sample

lib/main.dart
import 'dart:math';

import 'package:flutter/material.dart';

// Lays out fresh multi-span paragraphs every frame while the previous
// frame rasterizes on the skwasm render worker. Knobs:
// ?paragraphs=N&spans=M
void main() => runApp(const ReproApp());

final int kParagraphs =
    int.tryParse(Uri.base.queryParameters['paragraphs'] ?? '') ?? 48;
final int kSpans = int.tryParse(Uri.base.queryParameters['spans'] ?? '') ?? 12;

class ReproApp extends StatefulWidget {
  const ReproApp({super.key});

  @override
  State<ReproApp> createState() => _ReproAppState();
}

class _ReproAppState extends State<ReproApp>
    with SingleTickerProviderStateMixin {
  late final AnimationController _spin =
      AnimationController(vsync: this, duration: const Duration(seconds: 1))
        ..repeat();
  final Random _rand = Random(42);
  int _frame = 0;

  static const List<String> _words = [
    'resume', 'chapter', 'device', 'album', 'queue', 'shelf', 'needle',
    'groove', 'sleeve', 'liner', 'vinyl', 'deck', 'wax', 'fade', 'tempo',
    'verse',
  ];

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: AnimatedBuilder(
        animation: _spin,
        builder: (context, _) {
          _frame++;
          final paragraphs = List<Widget>.generate(kParagraphs, (i) {
            final spans = List<TextSpan>.generate(kSpans, (j) {
              final word = _words[_rand.nextInt(_words.length)];
              return TextSpan(
                text: '$word${_frame % 97} ',
                style: TextStyle(
                  fontSize: 10.0 + ((_frame + i + j) % 13),
                  fontWeight: FontWeight
                      .values[(i + j + _frame) % FontWeight.values.length],
                  fontStyle:
                      (i + j) % 3 == 0 ? FontStyle.italic : FontStyle.normal,
                  letterSpacing: (_frame % 5) * 0.2,
                  color: Color(0xFF000000 | _rand.nextInt(0xFFFFFF)),
                ),
              );
            });
            return Text.rich(
              TextSpan(children: spans),
              textScaler: TextScaler.linear(0.9 + (_frame % 7) * 0.05),
            );
          });
          return Scaffold(
            body: Transform.rotate(
              angle: _spin.value * 2 * pi,
              child: SingleChildScrollView(
                child: Wrap(children: paragraphs),
              ),
            ),
          );
        },
      ),
    );
  }
}
serve.js (COOP/COEP static server, node)
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');

const root = path.join(__dirname, 'build', 'web');
const types = {
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
  '.wasm': 'application/wasm', '.json': 'application/json',
  '.otf': 'font/otf', '.ttf': 'font/ttf',
};

http.createServer((req, res) => {
  res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
  res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless');
  const url = new URL(req.url, 'http://x');
  let file = path.join(root, decodeURIComponent(url.pathname));
  if (url.pathname === '/') file = path.join(root, 'index.html');
  fs.readFile(file, (err, data) => {
    if (err) return void res.writeHead(404).end('not found');
    res.writeHead(200, {
      'Content-Type': types[path.extname(file)] || 'application/octet-stream',
    });
    res.end(data);
  });
}).listen(4499, '127.0.0.1', () => console.log('http://127.0.0.1:4499'));

Screenshots or Video

No response

Logs

Four distinct fault stacks from the same session, all through
SkStrike paths. Function names recovered by index from
canvaskit/skwasm.js.symbols of the same engine build; V8 truncates at
10 frames.

1. memory access out of bounds, glyph path generation while drawing
RuntimeError: memory access out of bounds
  FT_Outline_Decompose
  (anonymous namespace)::generateGlyphPathStatic(FT_FaceRec_*, SkPathBuilder*)
  SkScalerContext_FreeType::generatePath(SkGlyph const&)
  SkScalerContext::internalGetPath(SkGlyph&, SkArenaAlloc*, ...)
  SkScalerContext::makeGlyph(SkPackedGlyphID, SkArenaAlloc*)
  SkStrike::digestFor(skglyph::ActionType, SkPackedGlyphID)
  sktext::gpu::SubRunContainer::MakeInAlloc(...)
  skgpu::ganesh::Device::onDrawGlyphRunList(...)
  SkDevice::drawGlyphRunList(...)
  SkCanvas::onDrawGlyphRunList(...)
2. unaligned atomic, glyph metrics during shaping (layout side)
RuntimeError: operation does not support unaligned accesses
  SkPath::tryMakeTransform(SkMatrix const&) const
  SkPath::makeTransform(SkMatrix const&) const
  SkScalerContext::internalGetPath(SkGlyph&, SkArenaAlloc*, ...)
  SkScalerContext::makeGlyph(SkPackedGlyphID, SkArenaAlloc*)
  SkStrike::digestFor(skglyph::ActionType, SkPackedGlyphID)
  SkStrike::glyph(SkPackedGlyphID)
  SkStrike::internalPrepare(SkSpan<unsigned short const>, ...)
  SkBulkGlyphMetrics::glyphs(SkSpan<unsigned short const>)
  SkFont::getWidthsBounds(...)
  (anonymous namespace)::skhb_glyph_h_advances(hb_font_t*, ...)
3. unaligned atomic in SkSafeUnref, stroking a glyph path while drawing
RuntimeError: operation does not support unaligned accesses
  void SkSafeUnref<SkPathData>(SkPathData*)
  SkPathStroker::finishContour(bool, bool)
  SkStrokeRec::applyToPath(SkPathBuilder*, SkPath const&) const
  SkScalerContext::internalGetPath(SkGlyph&, SkArenaAlloc*, ...)
  SkScalerContext::makeGlyph(SkPackedGlyphID, SkArenaAlloc*)
  SkStrike::digestFor(skglyph::ActionType, SkPackedGlyphID)
  sktext::gpu::SubRunContainer::MakeInAlloc(...)
  skgpu::ganesh::Device::onDrawGlyphRunList(...)
  SkDevice::drawGlyphRunList(...)
  SkCanvas::onDrawGlyphRunList(...)
4. memory access out of bounds inside malloc during paragraph layout (the flavor our production app hits; allocator metadata already corrupt by then)
RuntimeError: memory access out of bounds
  emscripten_builtin_malloc
  operator_new_impl(unsigned long)
  operator new(unsigned long)
  std::__2::allocator<unsigned long>::allocate(unsigned long)
  std::__2::vector<unsigned long, ...>::__vallocate(unsigned long)
  std::__2::vector<SkString, ...>::vector(std::__2::vector<SkString, ...> const&)
  skia::textlayout::TextStyle::TextStyle(skia::textlayout::TextStyle const&)
  skia::textlayout::Block::Block(skia::textlayout::Block const&)
  skia_private::TArray<skia::textlayout::Block, true>::TArray(...)
  (a capture from our app with the same tail continues into
  ParagraphImpl::layout(float) and the paragraph_layout export)
Per-thread state of a frozen renderer (/proc sampled twice, 1.2s apart; ticks are 10ms)
pid 4866 (renderer of the frozen page)
  tid 4866  CrRendererMain   state=R  cpu delta 123 ticks
  tid 5035  DedicatedWorker  state=R  cpu delta 126 ticks
  tid 4888  Compositor       state=S  wchan=do_epoll_wait  cpu delta 2
  (16 other threads sleeping, 0 to 1 ticks)
GPU process: all threads sleeping. The two hot threads stay pinned
like this for minutes, until the tab is killed.

Flutter Doctor output

Flutter 3.44.6 • channel stable • https://github.com/flutter/flutter.git
Framework • revision ee80f08bbf • 2026-07-08
Engine • hash d3a3293399556a85388faf8c6f0723a7a5597aa8 (revision 83675ed276)
Tools • Dart 3.12.2 • DevTools 2.57.0

Browser: Chromium 149.0.7827.55 (Playwright's build, new headless
mode; that is also where our e2e suite runs and where every number
above comes from). Host: Ubuntu 25.10 under WSL2, x86_64,
crossOriginIsolated true on the page. I have not yet tried headed
Chrome or other browsers.

Activity

  1. added
    c: crashStack traces logged to the console
    engineflutter/engine related. See also e: labels.
    platform-webWeb applications specifically
    e: wasmIssues related to the wasm build of Flutter Web.
    e: web_skwasmSkwasm rendering backend for web
    in triagePresently being triaged by the triage team
    on Jul 27, 2026
  2. mbcorona commented on Jul 27, 2026

    @mbcorona
    Member

    Thanks for the detailed report and the PR raised.
    Routing to team-web for evaluation.

  3. added
    team-webOwned by Web platform team
    and removed
    in triagePresently being triaged by the triage team
    on Jul 27, 2026
  4. added
    P2Important issues not at the top of the work list
    triaged-webTriaged by Web platform team
    on Jul 29, 2026
  5. eyebrowsoffire commented on Jul 31, 2026

    @eyebrowsoffire
    Contributor

    Hey @ColeSpringer, thanks for the detailed report and the PR with the fix! This is actually something we already have observed in our experimental wimp (web impeller) implementation (which also uses skia for text layout and glyph drawing). You can see some details here in this GitHub Issue: #178749

    I'm in discussion with the skia team to get an officially supported flag which makes the strike cache thread-local (see the skia bug here https://g-issues.skia.org/issues/449942001). They already have a highly experimental (and quite hidden) flag that does this, and in my testing, turning that flag on resolves these issues in our wimp implementation.

    Because of some priority shifts, the work to make that flag more official has been waiting for a bit, but since your report actually shows that this is happening with skwasm and not just our experimental wimp implementation that this is a higher impact issue than we originally thought. So work is being shifted around to prioritize exposing this flag.

    Thank you for the PR, but I'd like to hold off on merging right away because I think turning pthreads on has a lot of downsides. Your PR does have some useful test cases. Once skia exposes this thread-local strike cache flag, would you be open to incorporating that into your PR in lieu of the pthreads flags so we can make sure your test cases work?

  6. ColeSpringer commented on Jul 31, 2026

    @ColeSpringer
    ContributorAuthor

    Sounds good, thanks for getting back to me.

    Once skia exposes this thread-local strike cache flag, would you be open to incorporating that into your PR in lieu of the pthreads flags so we can make sure your test cases work?

    Sure, that's no problem. I'll keep an eye out for when it lands.

  7. github-actions commented on Aug 19, 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.

  8. locked as resolved and limited conversation to collaborators on Aug 19, 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

    P2Important issues not at the top of the work listc: 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 teamtriaged-webTriaged by Web platform team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions