Repository navigation
[skwasm] Canvas.drawAtlas crashes "memory access out of bounds" when batch exceeds ~2000 entries (wasm stack overflow in withStackScope) #185995
Description
Steps to reproduce
Call Canvas.drawAtlas with more than ~2000 transforms when running with --wasm and the skwasm renderer. The wasm runtime traps with RuntimeError: memory access out of bounds.
Real-world trigger: rendering a moderately large Tiled tilemap (64 × 64 tiles = 4096 entries) via package:flame SpriteBatch.render, which is what package:flame_tiled uses internally.
Minimal Dart-only reproduction:
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
void main() => runApp(const ReproApp());
class ReproApp extends StatefulWidget {
const ReproApp({super.key});
@override
State<ReproApp> createState() => _ReproAppState();
}
class _ReproAppState extends State<ReproApp> {
ui.Image? _image;
@override
void initState() {
super.initState();
_decode();
}
Future<void> _decode() async {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
Uint8List.fromList(List<int>.filled(4 * 32 * 32, 255)),
32,
32,
ui.PixelFormat.rgba8888,
completer.complete,
);
final image = await completer.future;
if (!mounted) return;
setState(() => _image = image);
}
@override
Widget build(BuildContext context) {
final image = _image;
return WidgetsApp(
color: const Color(0xFF000000),
builder: (_, _) => image == null
? const SizedBox()
: CustomPaint(
painter: _AtlasPainter(image),
child: const SizedBox.expand(),
),
);
}
}
class _AtlasPainter extends CustomPainter {
_AtlasPainter(this.image);
final ui.Image image;
@override
void paint(Canvas canvas, Size size) {
const n = 4096; // ~2000+ also triggers it
final transforms = List<RSTransform>.generate(
n,
(i) => RSTransform(1, 0, (i % 64) * 32.0, (i ~/ 64) * 32.0),
);
final rects = List<Rect>.generate(
n,
(_) => const Rect.fromLTWH(0, 0, 32, 32),
);
canvas.drawAtlas(image, transforms, rects, null, null, null, Paint());
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}Run: flutter run -d chrome --wasm with _flutter.loader.load({config: {renderer: "skwasm"}}) in web/flutter_bootstrap.js.
Expected results
drawAtlas renders the atlas without crashing.
Actual results
Uncaught RuntimeError: memory access out of bounds
at $malloc (skwasm.wasm)
at $func5792 (skwasm.wasm)
at $func222 (skwasm.wasm)
at $paint_create (skwasm.wasm)
at $paintCreate (main.dart.wasm)
at $SkwasmPaint.toRawPaint (paint.dart:16)
at $SkwasmCanvas.drawAtlas closure inside withStackScope (canvas.dart:331)
at $withStackScope (raw_memory.dart:228)
at $SkwasmCanvas.drawAtlas (canvas.dart:324)
Root cause
SkwasmCanvas.drawAtlas writes the RSTransform, Rect, and (optional) Color arrays into the wasm stack via withStackScope + _emscripten_stack_alloc (lib/web_ui/lib/src/engine/skwasm/skwasm_impl/canvas.dart, _emscripten_stack_alloc binding in raw/raw_memory.dart).
Each entry costs:
RSTransform→ 16 bytes (4 × float32)Rect→ 16 bytes (4 × float32)Color(if non-null) → 4 bytes (uint32)
For our 64 × 64 tilemap, transforms.length == rects.length == 4096, so withStackScope writes 4096 × (16 + 16) = 131 072 bytes (128 KB) before calling paint.toRawPaint().
The skwasm wasm module is built without -sSTACK_SIZE in engine/src/flutter/skwasm/BUILD.gn (ldflags), so it uses the Emscripten default of 64 KB. The 128 KB stack-scope allocation overflows into the heap. The conversions themselves don't trap (they just memcpy into linear memory), but the next allocation (the C++ paint_create calling malloc) traps because the heap allocator's bookkeeping is now corrupted — hence the crash appearing on paintCreate, downstream of the actual overflow.
The same hazard exists for any withStackScope-using API that operates on user-supplied lists: drawPoints, drawRawPoints, drawVertices (less common because Vertices is preconverted), etc. drawAtlas is the most exposed because tilemap renderers (Flame, Forge2D, custom) routinely batch thousands of entries.
Repro instrumentation
I patched lib/_skwasm_impl/skwasm_impl/canvas.dart to log the call site and verified before crash:
[SKWASM-DBG] drawAtlas: transforms=4096 rects=4096 colors=null
bytesOnStack=131072 blend=null atlas=800x32 cullRect=null
[SKWASM-DBG] first transform scos=1.0 ssin=0.0 tx=0.0 ty=0.0
[SKWASM-DBG] last transform scos=1.0 ssin=0.0 tx=2016.0 ty=2016.0
[SKWASM-DBG] first rect Rect.fromLTRB(224.0, 0.0, 256.0, 32.0)
[SKWASM-DBG] last rect Rect.fromLTRB(704.0, 0.0, 736.0, 32.0)
[SKWASM-DBG] about to call paint.toRawPaint()
← crash
Inputs are sane (no NaN, no enormous values); only the cumulative stack-scope size is the problem.
Workarounds (verified)
-
Project side: pass
useAtlas: falsetoflame_tiled.TiledComponent.load(...)— switchesSpriteBatchto onedrawImageRectcall per tile. Boots fine but drops to ~6 FPS in debug / ~100 FPS in profile (vs. ~120 FPS withcanvaskit + useAtlas). -
SDK patch (with
useAtlas: true, the default): chunkSkwasmCanvas.drawAtlasitself into batches of 1024 entries (~32 KB each), so the upstream caller can keep usingdrawAtlasnormally. I rebuiltdart2wasm_platform.dillwith this patch viakernel_worker_aot.dart.snapshotand confirmed the game renders correctly at full FPS, no crash. Diff:void drawAtlas(...) { const int maxBatch = 1024; final int n = transforms.length; if (n <= maxBatch) return _drawAtlasChunk(...); for (int i = 0; i < n; i += maxBatch) { final int end = i + maxBatch < n ? i + maxBatch : n; _drawAtlasChunk( atlas, transforms.sublist(i, end), rects.sublist(i, end), colors?.sublist(i, end), blendMode, cullRect, paint, ); } } void _drawAtlasChunk(...) => withStackScope((scope) { /* original body */ });
The
sublistoverhead is negligible compared to the per-batch GPU draw call.
Suggested upstream fix
Open to maintainer preference. Three options ordered by invasiveness:
A. Engine-side (smallest diff, kicks the can up): add -sSTACK_SIZE=262144 (256 KB) or larger to skwasm/BUILD.gn ldflags. Handles 8 K+ entries comfortably but doesn't solve the general class.
B. Dart-side chunking in drawAtlas / drawPoints / drawRawAtlas / drawRawPoints (verified for drawAtlas).
C. Heap fallback in StackScope: when the requested allocation would exceed available stack (emscripten_stack_get_free()), fall back to malloc and free it after the withStackScope closure returns. Most correct, addresses any future caller automatically.
I'm happy to send a PR for whichever direction is preferred. (B) is what we ship locally today.
Logs
$ flutter --version
Flutter 3.44.0-0.3.pre • channel [user-branch] • unknown source
Framework • revision 22533d1211 (5 days ago) • 2026-04-29 15:13:02 -0700
Engine • hash 285ae52077f0a82b1d845a3649eeab4e47a1340d (revision 73dc1ccd62)
Tools • Dart 3.12.0 (build 3.12.0-327.4.beta) • DevTools 2.57.0Reproducible on macOS 15 (darwin 25.3.0, arm64) with Chrome stable.
Flutter Doctor output
I can attach if useful — bug is renderer-internal, not platform-specific.
Activity
saurabh-mirajkar commentedon May 5, 2026
@Ortes Thanks for the report. I'm unable to check the code sample as I'm getting a few errors. Kindly share a working sample to replicate the behavior and full output of
flutter doctor -v. Labelling this asneed repro infofor now.Ortes commentedon May 5, 2026
@saurabh-mirajkar Fixed the sample in the issue body — sorry, the previous repro didn't compile. Should be reproducible now.
saurabh-mirajkar commentedon May 5, 2026
@Ortes Thanks for updating the sample. I give check on that. Also, can you share this detail? This will help me to determine which channel to check this behavior.
Ortes commentedon May 5, 2026
@saurabh-mirajkar here you go:
flutter doctor -vNote: the
[user-branch]warning is just because my local checkout is on a custom branch — the framework revision (22533d1211, 2026-04-29) is the standard beta tip.saurabh-mirajkar commentedon May 5, 2026
@Ortes Thanks for the details.
Noting that your setup is reported as [user-branch], even though the revision corresponds to the beta tip — this still indicates a non-standard checkout.
To properly validate this as a framework issue, could you confirm:
This will help determine if the issue is in Flutter itself or related to local setup/custom configuration.
[skwasm] Chunk drawAtlas/drawRawAtlas to avoid wasm stack overflow
Ortes commentedon May 5, 2026
@saurabh-mirajkar Confirmed — reproduces on stock Flutter
master(no custom branch) in a freshflutter createproject.Repro repo: https://github.com/Ortes/skwasm-drawatlas-oob-repro
Browser console shows immediately on first paint:
Answering your three questions
1. Official channel, no custom branch. Tested on
masterviafvm install master:The repro repo was generated by
flutter create --platforms=webfrom this exact SDK and crashes on first paint. Also reproduces onbeta(3.44.0-0.3.pre, framework rev22533d1211) — same root cause.2. Fresh project, no extra deps. Only
flutter/material.dartanddart:ui. The atlas is a 16×16 red rectangle built at runtime viaPictureRecorder— no assets, no third-party packages. The only project files I authored on top offlutter createarelib/main.dart(the painter) andweb/flutter_bootstrap.js(see below).pubspec.yamlis the default with zero added dependencies.3. How skwasm is enabled. You're right that
flutter createno longer emitsweb/flutter_bootstrap.js. I added one explicitly to force the renderer:Without that file, the loader auto-picks canvaskit on this machine and the bug doesn't surface. The bug is skwasm-specific — the wasm stack budget (Emscripten default 64 KiB) overflows when
convertRSTransformsToNative/convertRectsToNative/convertColorArrayToNativeallocate ~36 bytes × ~1800 entries insidewithStackScope. Below ~1800 entries it fits; the repro uses 4096 to be unambiguous.Fix is in #185997 — chunking each native
canvasDrawAtlascall to ≤ 1024 entries. Verified that applying that PR on top of the same master engine makes the repro render correctly.3 remaining items
jason-simmons commentedon May 7, 2026
I'm working on a PR that uses heap allocation for buffers that do not fit on the stack (similar to suggestion 2 in #185997 (comment))
That will also fix other APIs that may allocate buffers that exceed available stack space (such as #182616).
Ortes commentedon May 7, 2026
Thanks! even better then my fix! feel free to close mine when yours is ready
[web] Use heap allocation for buffers that would consume too much spa…
[web] Use heap allocation for buffers that would consume too much spa…
github-actions commentedon May 27, 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.