Skip to content

[skwasm] Canvas.drawAtlas crashes "memory access out of bounds" when batch exceeds ~2000 entries (wasm stack overflow in withStackScope) #185995

Closed

Description

@Ortes

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)

  1. Project side: pass useAtlas: false to flame_tiled.TiledComponent.load(...) — switches SpriteBatch to one drawImageRect call per tile. Boots fine but drops to ~6 FPS in debug / ~100 FPS in profile (vs. ~120 FPS with canvaskit + useAtlas).

  2. SDK patch (with useAtlas: true, the default): chunk SkwasmCanvas.drawAtlas itself into batches of 1024 entries (~32 KB each), so the upstream caller can keep using drawAtlas normally. I rebuilt dart2wasm_platform.dill with this patch via kernel_worker_aot.dart.snapshot and 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 sublist overhead 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.0

Reproducible 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

  1. saurabh-mirajkar commented on May 5, 2026

    @saurabh-mirajkar
    Contributor

    @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 as need repro info for now.

    Image
  2. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    needs repro infoAutomated crash report whose cause isn't yet known
    and removed
    in triagePresently being triaged by the triage team
    on May 5, 2026
  3. Ortes commented on May 5, 2026

    @Ortes
    ContributorAuthor

    @saurabh-mirajkar Fixed the sample in the issue body — sorry, the previous repro didn't compile. Should be reproducible now.

  4. saurabh-mirajkar commented on May 5, 2026

    @saurabh-mirajkar
    Contributor

    full output of flutter doctor -v

    @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.

  5. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on May 5, 2026
  6. Ortes commented on May 5, 2026

    @Ortes
    ContributorAuthor

    @saurabh-mirajkar here you go:

    flutter doctor -v
    [!] Flutter (Channel [user-branch], 3.44.0-0.3.pre, on macOS 26.3.1 25D771280a darwin-arm64, locale en-FR) [567ms]
        ! Flutter version 3.44.0-0.3.pre on channel [user-branch] at /Users/ortes/flutter
          Currently on an unknown channel. Run `flutter channel` to switch to an official channel.
          If that doesn't fix the issue, reinstall Flutter by following instructions at https://flutter.dev/setup.
        ! Upstream repository unknown source is not a standard remote.
          Set environment variable "FLUTTER_GIT_URL" to unknown source to dismiss this error.
        • Framework revision 22533d1211 (6 days ago), 2026-04-29 15:13:02 -0700
        • Engine revision 73dc1ccd62
        • Dart version 3.12.0 (build 3.12.0-327.4.beta)
        • DevTools version 2.57.0
        • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration, enable-riscv64
    
    [✓] Android toolchain - develop for Android devices (Android SDK version 35.0.1) [6.5s]
        • Android SDK at /Users/ortes/Library/Android/sdk/
        • Emulator version 35.4.9.0 (build_id 13025442) (CL:N/A)
        • Platform android-36, build-tools 35.0.1
        • Java version OpenJDK Runtime Environment (build 21.0.6+-13368085-b895.109)
        • All Android licenses accepted.
    
    [✓] Xcode - develop for iOS and macOS (Xcode 26.2) [1,355ms]
        • Xcode at /Applications/Xcode.app/Contents/Developer
        • Build 17C52
        • CocoaPods version 1.16.2
    
    [✓] Chrome - develop for the web [4ms]
        • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
    
    [✓] Connected device (3 available) [7.3s]
        • macOS (desktop) • macos  • darwin-arm64   • macOS 26.3.1 25D771280a darwin-arm64
        • Chrome (web)    • chrome • web-javascript • Google Chrome 147.0.7727.138
    
    [✓] Network resources [1,581ms]
        • All expected network resources are available.
    
    ! Doctor found issues in 1 category.
    

    Note: 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.

  7. saurabh-mirajkar commented on May 5, 2026

    @saurabh-mirajkar
    Contributor

    @Ortes Thanks for the details.

    Run: flutter run -d chrome --wasm with _flutter.loader.load({config: {renderer: "skwasm"}}) in web/flutter_bootstrap.js.

    Note: the [user-branch] warning is just because my local checkout is on a custom branch — the framework revision (22533d1, 2026-04-29) is the standard beta tip.

    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:

    • Whether this reproduces on an official Flutter channel (beta or master) without a custom branch
    • Whether the issue occurs in a fresh Flutter web project without additional configuration
    • How skwasm is being enabled, since web/flutter_bootstrap.js is not present by default in a newly created project

    This will help determine if the issue is in Flutter itself or related to local setup/custom configuration.

  8. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on May 5, 2026
  9. Ortes commented on May 5, 2026

    @Ortes
    ContributorAuthor

    @saurabh-mirajkar Confirmed — reproduces on stock Flutter master (no custom branch) in a fresh flutter create project.

    Repro repo: https://github.com/Ortes/skwasm-drawatlas-oob-repro

    git clone https://github.com/Ortes/skwasm-drawatlas-oob-repro
    cd skwasm-drawatlas-oob-repro
    flutter pub get
    flutter run -d chrome --wasm
    

    Browser console shows immediately on first paint:

    RuntimeError: memory access out of bounds
        at https://www.gstatic.com/flutter-canvaskit/<engine-hash>/skwasm.wasm:wasm-function[274]:0xe5a3
        ...
    

    Answering your three questions

    1. Official channel, no custom branch. Tested on master via fvm install master:

    Flutter 3.44.0-1.0.pre-427 • channel master • https://github.com/flutter/flutter.git
    Framework • revision 789b8d42d9 (2026-05-05)
    Engine • hash ee2226561d94a3b2010cb07d3a3d2070691287c0 (revision fbeb606531)
    Tools • Dart 3.13.0 (build 3.13.0-97.0.dev)
    

    The repro repo was generated by flutter create --platforms=web from this exact SDK and crashes on first paint. Also reproduces on beta (3.44.0-0.3.pre, framework rev 22533d1211) — same root cause.

    2. Fresh project, no extra deps. Only flutter/material.dart and dart:ui. The atlas is a 16×16 red rectangle built at runtime via PictureRecorder — no assets, no third-party packages. The only project files I authored on top of flutter create are lib/main.dart (the painter) and web/flutter_bootstrap.js (see below). pubspec.yaml is the default with zero added dependencies.

    3. How skwasm is enabled. You're right that flutter create no longer emits web/flutter_bootstrap.js. I added one explicitly to force the renderer:

    {{flutter_js}}
    {{flutter_build_config}}
    
    _flutter.loader.load({
      config: {
        renderer: "skwasm",
      },
    });

    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 / convertColorArrayToNative allocate ~36 bytes × ~1800 entries inside withStackScope. Below ~1800 entries it fits; the repro uses 4096 to be unambiguous.

    Fix is in #185997 — chunking each native canvasDrawAtlas call to ≤ 1024 entries. Verified that applying that PR on top of the same master engine makes the repro render correctly.

  10. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on May 5, 2026
  11. 3 remaining items

  12. added
    platform-webWeb applications specifically
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-webOwned by Web platform team
    on May 6, 2026
  13. added
    triaged-webTriaged by Web platform team
    P2Important issues not at the top of the work list
    on May 6, 2026
  14. jason-simmons commented on May 7, 2026

    @jason-simmons
    Member

    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).

  15. Ortes commented on May 7, 2026

    @Ortes
    ContributorAuthor

    Thanks! even better then my fix! feel free to close mine when yours is ready

  16. github-actions commented on May 27, 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.

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

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listhas reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-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