flutter / flutter

Flutter makes it easy and fast to build beautiful apps for mobile and beyond
https://flutter.dev
BSD 3-Clause "New" or "Revised" License
161.86k stars 26.57k forks source link

Strange issue with loading from root bundle in an async void method #145972

Closed navaronbracke closed 1 month ago

navaronbracke commented 1 month ago

I filed the following issue here rather than in dart-lang/sdk, because I'm not sure if it is an issue with the root bundle or with awaits

Steps to reproduce

  1. flutter create bug
  2. Paste the code sample into main.dart
  3. Add an .env file to the assets of the app. Add FLAG=test to said file (the value doesn't matter)
  4. Run the app (I ran it on MacOS, but the platform is irrelevant)
  5. Stop the app using q
  6. Comment out the line await Environment.initializeAsync(); and uncomment the line Environment.initialize();
  7. Run the app again
  8. The app crashes due to the StateError('not initialized')

Expected results

I would expect the void initialize() async {} to be idempotent to the awaited Future<void> initializeAsync() async {}.

The void method should only return after the body has completed, including any awaits in the implementation.

Actual results

Only the method that returns a Future (that is awaited) results in correct behavior.

In the logs below, we can see that the log flutter: initialize(): env = {FLAG: production} happens after the framework tried building the MyApp widget, even though the initialize() method should have fully completed before runApp() is invoked.

Code sample

Code sample ```dart import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; DotEnv dotenv = DotEnv(); void main() async { WidgetsFlutterBinding.ensureInitialized(); await Environment.initializeAsync(); //Environment.initialize(); runApp(const MyApp()); } class Environment { static Future initializeAsync() async { await dotenv.load(fileName: '.env'); print('initializeAsync(): env = ${dotenv.env}'); } static void initialize() async { await dotenv.load(fileName: '.env'); print('initialize(): env = ${dotenv.env}'); } } class DotEnv { bool _isInitialized = false; final Map _envMap = {}; Map get env { if (!_isInitialized) { throw StateError('not initialized'); } return _envMap; } Future load({required String fileName}) async { WidgetsFlutterBinding.ensureInitialized(); _envMap.clear(); final String envString = await rootBundle.loadString(fileName); final List linesFromFile = envString.isEmpty ? [] : envString.split('\n'); for (final String line in linesFromFile) { final List parts = line.split('='); if (parts.isEmpty || parts.length != 2) { continue; } _envMap[parts[0].trim()] = parts[1].trim(); } _isInitialized = true; } } 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: Scaffold( body: Center( child: Text('env = ${dotenv.env}'), ), ), ); } } ```

Screenshots or Video

Screenshots / Video demonstration N/A

Logs

Logs ### First run, using `await Environment.initializeAsync();` ```console navaronbracke@MacBook-Pro-van-Navaron flavor_sample % flutter run -d macos Launching lib/main.dart on macOS in debug mode... Building macOS application... ✓ Built build/macos/Build/Products/Debug/flavor_sample.app [IMPORTANT:flutter/shell/platform/darwin/graphics/FlutterDarwinContextMetalSkia.mm(66)] Using the Skia rendering backend (Metal). 2024-03-29 12:00:34.052 flavor_sample[12007:94433] WARNING: Secure coding is automatically enabled for restorable state! However, not on all supported macOS versions of this application. Opt-in to secure coding explicitly by implementing NSApplicationDelegate.applicationSupportsSecureRestorableState:. flutter: initializeAsync(): env = {FLAG: test} Syncing files to device macOS... 37ms Flutter run key commands. r Hot reload. 🔥🔥🔥 R Hot restart. h List all available interactive commands. d Detach (terminate "flutter run" but leave application running). c Clear the screen q Quit (terminate the application on the device). A Dart VM Service on macOS is available at: http://127.0.0.1:53702/Ko1pattNBpk=/ The Flutter DevTools debugger and profiler on macOS is available at: http://127.0.0.1:9101?uri=http://127.0.0.1:53702/Ko1pattNBpk=/ Application finished. ``` ### Second run, using `Environment.initialize();` ```console navaronbracke@MacBook-Pro-van-Navaron flavor_sample % flutter run -d macos Launching lib/main.dart on macOS in debug mode... Building macOS application... ✓ Built build/macos/Build/Products/Debug/flavor_sample.app [IMPORTANT:flutter/shell/platform/darwin/graphics/FlutterDarwinContextMetalSkia.mm(66)] Using the Skia rendering backend (Metal). 2024-03-29 12:03:15.937 flavor_sample[12357:96922] WARNING: Secure coding is automatically enabled for restorable state! However, not on all supported macOS versions of this application. Opt-in to secure coding explicitly by implementing NSApplicationDelegate.applicationSupportsSecureRestorableState:. Syncing files to device macOS... 57ms Flutter run key commands. r Hot reload. 🔥🔥🔥 R Hot restart. h List all available interactive commands. d Detach (terminate "flutter run" but leave application running). c Clear the screen q Quit (terminate the application on the device). A Dart VM Service on macOS is available at: http://127.0.0.1:53810/lF_xwYkKW3U=/ ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════ The following StateError was thrown building MyApp(dirty): Bad state: not initialized The relevant error-causing widget was: MyApp MyApp:file:///Users/navaronbracke/Desktop/flavor_sample/lib/main.dart:12:16 When the exception was thrown, this was the stack: #0 DotEnv.env (package:flavor_sample/main.dart:33:7) #1 MyApp.build (package:flavor_sample/main.dart:72:39) #2 StatelessElement.build (package:flutter/src/widgets/framework.dart:5557:49) #3 ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:5487:15) #4 Element.rebuild (package:flutter/src/widgets/framework.dart:5203:7) #5 ComponentElement._firstBuild (package:flutter/src/widgets/framework.dart:5469:5) #6 ComponentElement.mount (package:flutter/src/widgets/framework.dart:5463:5) ... Normal element mounting (27 frames) #33 Element.inflateWidget (package:flutter/src/widgets/framework.dart:4340:16) #34 Element.updateChild (package:flutter/src/widgets/framework.dart:3849:18) #35 _RawViewElement._updateChild (package:flutter/src/widgets/view.dart:291:16) #36 _RawViewElement.mount (package:flutter/src/widgets/view.dart:314:5) ... Normal element mounting (7 frames) #43 Element.inflateWidget (package:flutter/src/widgets/framework.dart:4340:16) #44 Element.updateChild (package:flutter/src/widgets/framework.dart:3849:18) #45 RootElement._rebuild (package:flutter/src/widgets/binding.dart:1581:16) #46 RootElement.mount (package:flutter/src/widgets/binding.dart:1550:5) #47 RootWidget.attach. (package:flutter/src/widgets/binding.dart:1503:18) #48 BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2845:19) #49 RootWidget.attach (package:flutter/src/widgets/binding.dart:1502:13) #50 WidgetsBinding.attachToBuildOwner (package:flutter/src/widgets/binding.dart:1239:27) #51 WidgetsBinding.attachRootWidget (package:flutter/src/widgets/binding.dart:1221:5) #52 WidgetsBinding.scheduleAttachRootWidget. (package:flutter/src/widgets/binding.dart:1207:7) #56 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:184:12) (elided 3 frames from class _Timer and dart:async-patch) ════════════════════════════════════════════════════════════════════════════════════════════════════ flutter: initialize(): env = {FLAG: test} The Flutter DevTools debugger and profiler on macOS is available at: http://127.0.0.1:9101?uri=http://127.0.0.1:53810/lF_xwYkKW3U=/ ```

Flutter Doctor output

Doctor output ```console [✓] Flutter (Channel master, 3.21.0-17.0.pre.24, on macOS 14.3.1 23D60 darwin-x64, locale en-BE) • Flutter version 3.21.0-17.0.pre.24 on channel master at /Users/navaronbracke/Documents/flutter • Upstream repository git@github.com:navaronbracke/flutter.git • FLUTTER_GIT_URL = git@github.com:navaronbracke/flutter.git • Framework revision 1a2f34ab5b (8 hours ago), 2024-03-28 19:39:17 -0700 • Engine revision 68aa9ba386 • Dart version 3.4.0 (build 3.4.0-282.0.dev) • DevTools version 2.34.1 [✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0) • Android SDK at /Users/navaronbracke/Library/Android/sdk • Platform android-34, build-tools 34.0.0 • ANDROID_HOME = /Users/navaronbracke/Library/Android/sdk • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java • Java version OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874) • All Android licenses accepted. [✓] Xcode - develop for iOS and macOS (Xcode 15.3) • Xcode at /Applications/Xcode.app/Contents/Developer • Build 15E204a • CocoaPods version 1.15.2 [✓] Chrome - develop for the web • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome [✓] Android Studio (version 2023.2) • 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.9+0-17.0.9b1087.7-11185874) [✓] VS Code (version 1.87.2) • VS Code at /Applications/Visual Studio Code.app/Contents • Flutter extension version 3.84.0 [✓] Connected device (2 available) • macOS (desktop) • macos • darwin-x64 • macOS 14.3.1 23D60 darwin-x64 • Chrome (web) • chrome • web-javascript • Google Chrome 123.0.6312.87 [✓] Network resources • All expected network resources are available. • No issues found! ```
Clavum commented 1 month ago

The void method should only return after the body has completed, including any awaits in the implementation.

the initialize() method should have fully completed before runApp() is invoked

The only time that a method would wait to return until after the body has completed is when it is awaited.

You can try this in dartpad:

void main() async {
  initialize();
  print('Main method done');
}

void initialize() async {
  await Future.delayed(const Duration(seconds: 1));
  print('Future done');
}

When you execute this, we get:

Main method done
[One second delay]
Future done

This is the expected behavior.

To make this more clear, the same thing will happen even if initialize returns a Future<void> but is not awaited.

void main() async {
  initialize(); // Not awaited!
  print('Main method done');
}

Future<void> initialize() async { // Now a Future<void>
  await Future.delayed(const Duration(seconds: 1));
  print('Future done');
}

Using the await keyword tells the method to suspend execution of the method until the Future is complete. But we have not used await here, making the code execute synchronously, allowing the Future to run on its own without being waited on. We are given the choice in our code whether or not to await that Future by adding await.

Although when the initialize method returns void, we don't have that choice at all, which is bad. This is the reason for the existence of the avoid_void_async lint https://dart.dev/tools/linter-rules/avoid_void_async, which reminds us to make async methods return a Future so that any executor may choose to await the work.

There's a similar issue here, where we're not awaiting the initialize method when we probably want to. Let me introduce another lint, unawaited_futures: https://dart.dev/tools/linter-rules/unawaited_futures. This lint will warn us when we've called a method that returns a Future without awaiting it. In the rare case we actually want to "fire and forget", you can use unawaited(initialize()); to silence the lint.

To summarize, for a method to wait for a Future to complete, that method must await it. From the main method's perspective, it doesn't know the implementation of the initialize method, except that it's a Function that returns void. No matter how many awaits are in the initialize method's implementation, it has no effect unless the main method chooses to await that work.

navaronbracke commented 1 month ago

I did not know about avoid_void_async yet, TIL. I guess we should raise the awareness of avoid_void_async ?

And now that I see the example, it does make sense. Just feels weird that we could write void async methods, considering the hidden pitfall.

github-actions[bot] commented 2 weeks ago

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.