Repository navigation
[web][skwasm] Wasm heap corruption under concurrent text layout and rasterization with multi-threaded skwasm; page then spins at 100% CPU #190039
Description
Steps to reproduce
flutter create repro, replacelib/main.dartwith the sample belowflutter build web --wasm- Serve
build/webcross-origin isolated so skwasm picks its
multi-threaded mode. I used the node script below, which sends
Cross-Origin-Opener-Policy: same-originand
Cross-Origin-Embedder-Policy: credentialless - 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
RuntimeErrorfrom skwasm.wasm (memory access out of bounds, oroperation does not support unaligned accessesfrom 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
Sideline the skwasm heap race and serve engine and fonts from our origin
mbcorona commentedon Jul 27, 2026
Thanks for the detailed report and the PR raised.
Routing to team-web for evaluation.
eyebrowsoffire commentedon Jul 31, 2026
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?
ColeSpringer commentedon Jul 31, 2026
Sounds good, thanks for getting back to me.
Sure, that's no problem. I'll keep an eye out for when it lands.
[web] Use thread local strike caches in skwasm (flutter#190048)
github-actions commentedon Aug 19, 2026
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 -vand a minimal reproduction of the issue.