Skip to content

[web] Paint and Path memory is not reclaimed fast enough #153678

@filiph

Description

@filiph

Steps to reproduce

  1. Create new Flutter project: flutter create bug && cd bug
  2. Add a dependency on pkg:vector_math: flutter pub add vector_math (dependency on vector_math is almost certainly not needed but it makes the repro code cleaner)
  3. Replace lib/main.dart with the code below (in code sample section).
  4. Run in any non-web target (e.g. flutter run -d macos or flutter build macos) to ensure there is no memory leak (memory stays at a certain level):

Screenshot 2024-08-18 at 12 17 38

  1. Run on the web (e.g. flutter build web) in any configuration (release / profile / debug, wasm or not, etc.)

Expected results

Used memory doesn't grow.

Actual results

Memory used by the web app goes up linearly at several megabytes per second:

Screenshot 2024-08-18 at 12 57 41

After a couple of minutes, the memory consumed reaches 1.0 GB and keeps growing:

Screenshot 2024-08-18 at 12 22 33

I tried debugging this through Dart DevTools but Dart DevTools don't have access to memory info for web apps. All I can do is use Chrome DevTools. I can't say I understand what I'm looking at in Chrome DevTools, but here are some things I noticed:

  1. Basically all the RAM is taken by some JSArrayBufferData object:

Screenshot 2024-08-18 at 13 03 55

  1. This object seems to have something in common with a flutterCanvasKit object, and specifically with something called globalThis.

Screenshot 2024-08-19 at 09 31 43

Snapshot file from Chrome DevTools: Heap-20240818T130707.heapsnapshot.zip

I initially thought this had something to do with Flame but as you can see from the sample, the same issue appears on vanilla Flutter as well. // cc @zoeyfan

Code sample

Code sample

It's possible that the code could be made simpler but before I invest that kind of work, I'd like to make sure this is actually a bug and that I'm not overlooking something obvious or doing something stupid.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:vector_math/vector_math_64.dart' hide Colors;

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  final String title;

  const MyHomePage({super.key, required this.title});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: const Center(
        child: MemoryPressureWidget(),
      ),
    );
  }
}

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

  @override
  State<MemoryPressureWidget> createState() => _MemoryPressureWidgetState();
}

class _MemoryPressureWidgetState extends State<MemoryPressureWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  final List<PairedWanderer> wanderers = [];

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        for (final wanderer in wanderers)
          PairedWandererWidget(wanderer: wanderer),
      ],
    );
  }

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

  @override
  void initState() {
    super.initState();
    _createBatch(1000, const Size(500, 500));

    _controller = AnimationController(vsync: this);
  }

  void _createBatch(int batchSize, Size worldSize) {
    assert(batchSize.isEven);
    final random = Random(42);
    for (var i = 0; i < batchSize / 2; i++) {
      final a = PairedWanderer(
        velocity: (Vector2.random() - Vector2.all(0.5))..scale(100),
        worldSize: worldSize,
        position: Vector2(worldSize.width * random.nextDouble(),
            worldSize.height * random.nextDouble()),
      );
      final b = PairedWanderer(
        velocity: (Vector2.random() - Vector2.all(0.5))..scale(100),
        worldSize: worldSize,
        position: Vector2(worldSize.width * random.nextDouble(),
            worldSize.height * random.nextDouble()),
      );
      a.otherWanderer = b;
      b.otherWanderer = a;
      wanderers.add(a);
      wanderers.add(b);
    }
  }
}

class PairedWanderer {
  PairedWanderer? otherWanderer;

  final Vector2 position;

  final Vector2 velocity;

  final Size worldSize;

  PairedWanderer({
    required this.position,
    required this.velocity,
    required this.worldSize,
  });

  void update(double dt) {
    position.addScaled(velocity, dt);
    if (otherWanderer != null) {
      position.addScaled(otherWanderer!.velocity, dt * 0.25);
    }

    if (position.x < 0 && velocity.x < 0) {
      velocity.x = -velocity.x;
    } else if (position.x > worldSize.width && velocity.x > 0) {
      velocity.x = -velocity.x;
    }
    if (position.y < 0 && velocity.y < 0) {
      velocity.y = -velocity.y;
    } else if (position.y > worldSize.height && velocity.y > 0) {
      velocity.y = -velocity.y;
    }
  }
}

class PairedWandererWidget extends StatefulWidget {
  final PairedWanderer wanderer;

  const PairedWandererWidget({required this.wanderer, super.key});

  @override
  State<PairedWandererWidget> createState() => _PairedWandererWidgetState();
}

class _PairedWandererWidgetState extends State<PairedWandererWidget>
    with SingleTickerProviderStateMixin {
  late Ticker _ticker;

  Duration _lastElapsed = Duration.zero;

  @override
  void initState() {
    super.initState();
    _ticker = createTicker(_onTick);
    _ticker.start();
  }

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

  @override
  Widget build(BuildContext context) {
    return Positioned(
      left: widget.wanderer.position.x - 128 / 4,
      top: widget.wanderer.position.y - 128 / 4,
      child: const SizedBox(
        width: 8,
        height: 8,
        child: Placeholder(),
      ),
    );
  }

  void _onTick(Duration elapsed) {
    var dt = (elapsed - _lastElapsed).inMicroseconds / 1000000;
    dt = min(dt, 1 / 60);
    widget.wanderer.update(dt);
    _lastElapsed = elapsed;
    setState(() {});
  }
}

Screenshots or Video

Screenshots / Video demonstration

The code sample is compatible with Dartpad.

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
flutter doctor -v
[✓] Flutter (Channel stable, 3.24.0, on macOS 14.4.1 23E224 darwin-arm64,
    locale en-US)
    • Flutter version 3.24.0 on channel stable at
      /Users/filiph/fvm/versions/stable
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 80c2e84975 (3 weeks ago), 2024-07-30 23:06:49 +0700
    • Engine revision b8800d88be
    • Dart version 3.5.0
    • DevTools version 2.37.2

[✓] Android toolchain - develop for Android devices (Android SDK version
    35.0.0)
    • Android SDK at /Users/filiph/Library/Android/sdk
    • Platform android-35, build-tools 35.0.0
    • Java binary at: /Applications/Android
      Studio.app/Contents/jbr/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build
      17.0.7+0-17.0.7b1000.6-10550314)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 15.4)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 15F31d
    • CocoaPods version 1.15.0

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2023.1)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build
      17.0.7+0-17.0.7b1000.6-10550314)

[✓] IntelliJ IDEA Ultimate Edition (version 2023.3.2)
    • IntelliJ at /Users/filiph/Applications/IntelliJ IDEA Ultimate.app
    • Flutter plugin version 78.4.2
    • Dart plugin version 233.15271

[✓] VS Code (version 1.87.2)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension version 3.84.0

[✓] Connected device (3 available)
    • macOS (desktop)                 • macos                 • darwin-arm64
      • macOS 14.4.1 23E224 darwin-arm64
    • Mac Designed for iPad (desktop) • mac-designed-for-ipad • darwin
      • macOS 14.4.1 23E224 darwin-arm64
    • Chrome (web)                    • chrome                • web-javascript
      • Google Chrome 127.0.6533.120

[✓] Network resources
    • All expected network resources are available.

• No issues found!

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listc: performanceRelates to speed or footprint issues (see "perf:" labels)found in release: 3.24Found to occur in 3.24found in release: 3.25Found to occur in 3.25has reproducible stepsThe issue has been confirmed reproducible and is ready to work onperf: memoryPerformance issues related to memoryplatform-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