Last active
May 22, 2026 19:34
-
-
Save wispborne/070448335d0e65ab83d9a926dcd312fb to your computer and use it in GitHub Desktop.
dart_3.12.0-aot-crash
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /// Dart AOT codegen crash reproducer. | |
| /// | |
| /// Bug: `dart compile exe` generates native code that crashes with an access | |
| /// violation (0xC0000005). `dart run` (JIT) works fine. The same exe with the | |
| /// workaround (`fixed`) also works fine. | |
| /// | |
| /// Trigger: an async method containing a for-loop-with-await, where the method | |
| /// is part of a mutual-recursion cycle with another complex async method. The | |
| /// recursive call does not need to execute at runtime — it only needs to exist | |
| /// in the call graph. | |
| /// | |
| /// Workaround: extract the for-loop-with-await into a separate async method. | |
| /// | |
| /// Usage: | |
| /// dart compile exe repro.dart -o repro.exe | |
| /// ./repro.exe buggy # crashes (access violation) | |
| /// ./repro.exe fixed # completes fine (workaround applied) | |
| /// dart run repro.dart buggy # JIT — always works | |
| /// | |
| /// Confirmed: Dart 3.12.0 stable, windows_x64. | |
| import 'dart:async'; | |
| void debugPrint(String? message) { | |
| if (message != null) print(message); | |
| } | |
| class ModInfo { | |
| final String id; | |
| final String? name; | |
| final Version? version; | |
| final String? author; | |
| final List<Dependency> dependencies; | |
| ModInfo( | |
| this.id, { | |
| this.name, | |
| this.version, | |
| this.author, | |
| this.dependencies = const [], | |
| }); | |
| } | |
| class Version { | |
| final int major; | |
| final int minor; | |
| final int patch; | |
| final String? label; | |
| Version(this.major, this.minor, this.patch, {this.label}); | |
| @override | |
| String toString() => '$major.$minor.$patch${label != null ? '-$label' : ''}'; | |
| @override | |
| int get hashCode => Object.hash(major, minor, patch, label); | |
| @override | |
| bool operator ==(Object other) => | |
| other is Version && | |
| major == other.major && | |
| minor == other.minor && | |
| patch == other.patch && | |
| label == other.label; | |
| } | |
| class Dependency { | |
| final String? id; | |
| final String? name; | |
| final Version? version; | |
| Dependency({this.id, this.name, this.version}); | |
| } | |
| class Variant { | |
| final String id; | |
| final ModInfo modInfo; | |
| final bool isModInfoEnabled; | |
| Variant(this.id, this.modInfo, {this.isModInfoEnabled = true}); | |
| String get smolId { | |
| final idPart = modInfo.id.length > 6 | |
| ? modInfo.id.substring(0, 6) | |
| : modInfo.id; | |
| final vPart = modInfo.version?.toString() ?? 'unknown'; | |
| final hash = Object.hash(modInfo.id, modInfo.version).abs(); | |
| return '$idPart-$vPart-$hash'; | |
| } | |
| String get bestVersion => modInfo.version?.toString() ?? modInfo.id; | |
| List<DependencyCheck> checkDependencies( | |
| List<Variant> allVariants, | |
| List<String> enabledMods, | |
| String? gameVersion, | |
| ) { | |
| return modInfo.dependencies.map((dep) { | |
| final found = allVariants.where((v) => v.modInfo.id == dep.id); | |
| if (found.isEmpty) return DependencyCheck(dep, const Missing()); | |
| final enabled = found.where((v) => enabledMods.contains(v.modInfo.id)); | |
| if (enabled.isEmpty) return DependencyCheck(dep, Disabled(found.first)); | |
| return DependencyCheck(dep, const Satisfied()); | |
| }).toList(); | |
| } | |
| } | |
| sealed class SatisfiedAmount { | |
| const SatisfiedAmount(); | |
| } | |
| class Satisfied extends SatisfiedAmount { | |
| const Satisfied(); | |
| } | |
| class Missing extends SatisfiedAmount { | |
| const Missing(); | |
| } | |
| class Disabled extends SatisfiedAmount { | |
| final Variant? modVariant; | |
| const Disabled([this.modVariant]); | |
| } | |
| class VersionInvalid extends SatisfiedAmount { | |
| const VersionInvalid(); | |
| } | |
| class DependencyCheck { | |
| final Dependency dependency; | |
| final SatisfiedAmount satisfiedAmount; | |
| DependencyCheck(this.dependency, this.satisfiedAmount); | |
| } | |
| class Mod { | |
| final String id; | |
| final List<Variant> modVariants; | |
| final bool isEnabledInGame; | |
| Mod(this.id, this.modVariants, {this.isEnabledInGame = true}); | |
| bool isEnabled(Variant v) => v.isModInfoEnabled; | |
| List<Variant> get enabledVariants => | |
| modVariants.where((v) => v.isModInfoEnabled).toList(); | |
| Variant? get findFirstEnabled { | |
| for (final v in modVariants) { | |
| if (v.isModInfoEnabled) return v; | |
| } | |
| return null; | |
| } | |
| Variant? get findHighestEnabledVersion { | |
| final enabled = enabledVariants; | |
| if (enabled.isEmpty) return null; | |
| return enabled.reduce((a, b) { | |
| final av = a.modInfo.version; | |
| final bv = b.modInfo.version; | |
| if (av == null) return b; | |
| if (bv == null) return a; | |
| if (bv.major > av.major) return b; | |
| if (bv.minor > av.minor) return b; | |
| if (bv.patch > av.patch) return b; | |
| return a; | |
| }); | |
| } | |
| bool isEnabledInGameSync(List<String> enabledMods) => | |
| enabledMods.contains(id); | |
| } | |
| class StateStore { | |
| Map<String, bool> enabledMods = {'modA': true, 'modB': true}; | |
| List<Variant> allVariants = []; | |
| List<String> auditLog = []; | |
| bool autoValidateDependencies = true; | |
| String? gameVersion = '0.97a'; | |
| List<Mod> getMods() { | |
| final grouped = <String, List<Variant>>{}; | |
| for (final v in allVariants) { | |
| (grouped[v.modInfo.id] ??= []).add(v); | |
| } | |
| return grouped.entries | |
| .map( | |
| (e) => | |
| Mod(e.key, e.value, isEnabledInGame: enabledMods[e.key] == true), | |
| ) | |
| .toList(); | |
| } | |
| } | |
| class ModManager { | |
| final StateStore _state; | |
| ModManager(this._state) { | |
| _state.allVariants = [ | |
| Variant( | |
| 'modA-v1', | |
| ModInfo( | |
| 'modA', | |
| name: 'Mod Alpha', | |
| version: Version(1, 0, 0), | |
| dependencies: [Dependency(id: 'modB', name: 'Mod Beta')], | |
| ), | |
| ), | |
| Variant( | |
| 'modA-v2', | |
| ModInfo('modA', name: 'Mod Alpha', version: Version(2, 0, 0)), | |
| isModInfoEnabled: false, | |
| ), | |
| Variant( | |
| 'modB-v1', | |
| ModInfo('modB', name: 'Mod Beta', version: Version(1, 0, 0)), | |
| ), | |
| Variant( | |
| 'modC-v1', | |
| ModInfo( | |
| 'modC', | |
| name: 'Mod Charlie', | |
| version: Version(3, 2, 1, label: 'rc1'), | |
| dependencies: [ | |
| Dependency(id: 'modA', name: 'Mod Alpha'), | |
| Dependency(id: 'modD', name: 'Mod Delta'), | |
| ], | |
| ), | |
| isModInfoEnabled: false, | |
| ), | |
| ]; | |
| } | |
| /// BUGGY: crashes under AOT. Contains a for-loop-with-await and is mutually | |
| /// recursive with _validateModDependencies. | |
| Future<String> changeActiveModVariant_BUGGY( | |
| Mod mod, | |
| Variant? modVariant, { | |
| bool notifyWatchers = true, | |
| bool validateDependencies = true, | |
| }) async { | |
| final isDisablingMod = modVariant == null; | |
| debugPrint( | |
| isDisablingMod | |
| ? "Disabling ${mod.id}." | |
| : "Changing active variant of ${mod.id} to ${modVariant.smolId}. (current: ${mod.findFirstEnabled?.smolId}).", | |
| ); | |
| final modVariantParentModId = modVariant?.modInfo; | |
| if (modVariantParentModId != null && mod.id != modVariantParentModId.id) { | |
| throw Exception("Mod variant does not belong to mod ${mod.id}."); | |
| } | |
| if (modVariant != null && mod.isEnabled(modVariant)) { | |
| if (mod.modVariants.where((it) => mod.isEnabled(it)).length <= 1) { | |
| return 'Already enabled.'; | |
| } | |
| } | |
| final modInfoEnabledVariants = mod.modVariants | |
| .where((it) => it.isModInfoEnabled) | |
| .toList(); | |
| if (modVariant == null && modInfoEnabledVariants.isEmpty) { | |
| return 'Nothing to do.'; | |
| } | |
| // CRASH SITE: for-loop-with-await in a mutually recursive method. | |
| for (final variant in modInfoEnabledVariants) { | |
| if (variant.smolId != modVariant?.smolId) { | |
| try { | |
| await _disableModVariant( | |
| variant, | |
| disableModInVanillaLauncher: isDisablingMod, | |
| brickModInfo: !isDisablingMod && mod.modVariants.length > 1, | |
| reason: isDisablingMod | |
| ? "You disabled ${mod.id} (${variant.modInfo.version} was enabled before)." | |
| : "Changed ${mod.id} to ${modVariant!.modInfo.version}, so ${variant.bestVersion} has to be disabled.", | |
| ); | |
| } catch (e, st) { | |
| debugPrint("Error disabling mod variant: $e\n$st"); | |
| } | |
| } | |
| } | |
| if (!isDisablingMod) { | |
| await _enableModVariant( | |
| modVariant, | |
| mod, | |
| enableInVanillaLauncher: true, | |
| reason: "enable", | |
| ); | |
| } else { | |
| final disabledModVariants = mod.modVariants | |
| .where((v) => !v.isModInfoEnabled) | |
| .toList(); | |
| for (final disabledVariant in disabledModVariants) { | |
| try { | |
| await _enableModInfoFile(disabledVariant); | |
| } catch (e, st) { | |
| debugPrint("Error enabling mod_info.json file: $e\n$st"); | |
| } | |
| } | |
| } | |
| if (notifyWatchers) { | |
| await _reloadModVariants(onlyVariants: mod.modVariants); | |
| } | |
| if (validateDependencies) { | |
| await _validateModDependencies(modsToFreeze: [mod.id]); | |
| } | |
| return 'Done (buggy path).'; | |
| } | |
| /// FIXED: identical logic, but the for-loops-with-await are extracted into | |
| /// separate methods. This breaks the complex async state machine that AOT | |
| /// miscompiles. Runs fine under AOT. | |
| Future<String> changeActiveModVariant_FIXED( | |
| Mod mod, | |
| Variant? modVariant, { | |
| bool notifyWatchers = true, | |
| bool validateDependencies = true, | |
| }) async { | |
| final isDisablingMod = modVariant == null; | |
| debugPrint(isDisablingMod | |
| ? "Disabling ${mod.id}." | |
| : "Changing active variant of ${mod.id} to ${modVariant.smolId}. (current: ${mod.findFirstEnabled?.smolId})."); | |
| final modVariantParentModId = modVariant?.modInfo; | |
| if (modVariantParentModId != null && mod.id != modVariantParentModId.id) { | |
| throw Exception("Mod variant does not belong to mod ${mod.id}."); | |
| } | |
| if (modVariant != null && mod.isEnabled(modVariant)) { | |
| if (mod.modVariants.where((it) => mod.isEnabled(it)).length <= 1) { | |
| return 'Already enabled.'; | |
| } | |
| } | |
| final modInfoEnabledVariants = | |
| mod.modVariants.where((it) => it.isModInfoEnabled).toList(); | |
| if (modVariant == null && modInfoEnabledVariants.isEmpty) { | |
| return 'Nothing to do.'; | |
| } | |
| // Workaround: for-loop-with-await extracted into its own method. | |
| await _disableOtherVariants(modInfoEnabledVariants, modVariant, | |
| isDisablingMod: isDisablingMod, mod: mod); | |
| if (!isDisablingMod) { | |
| await _enableModVariant(modVariant, mod, | |
| enableInVanillaLauncher: true, reason: "enable"); | |
| } else { | |
| await _unbrickModInfoFiles(mod); | |
| } | |
| if (notifyWatchers) { | |
| await _reloadModVariants(onlyVariants: mod.modVariants); | |
| } | |
| if (validateDependencies) { | |
| await _validateModDependencies(modsToFreeze: [mod.id]); | |
| } | |
| return 'Done (fixed path).'; | |
| } | |
| Future<void> _disableOtherVariants( | |
| List<Variant> modInfoEnabledVariants, | |
| Variant? modVariant, { | |
| required bool isDisablingMod, | |
| required Mod mod, | |
| }) async { | |
| for (final variant in modInfoEnabledVariants) { | |
| if (variant.smolId != modVariant?.smolId) { | |
| try { | |
| await _disableModVariant( | |
| variant, | |
| disableModInVanillaLauncher: isDisablingMod, | |
| brickModInfo: !isDisablingMod && mod.modVariants.length > 1, | |
| reason: isDisablingMod | |
| ? "You disabled ${mod.id} (${variant.modInfo.version} was enabled before)." | |
| : "Changed ${mod.id} to ${modVariant!.modInfo.version}, so ${variant.bestVersion} has to be disabled.", | |
| ); | |
| } catch (e, st) { | |
| debugPrint("Error disabling mod variant: $e\n$st"); | |
| } | |
| } | |
| } | |
| } | |
| Future<void> _unbrickModInfoFiles(Mod mod) async { | |
| final disabledModVariants = | |
| mod.modVariants.where((v) => !v.isModInfoEnabled).toList(); | |
| for (final disabledVariant in disabledModVariants) { | |
| try { | |
| await _enableModInfoFile(disabledVariant); | |
| } catch (e, st) { | |
| debugPrint("Error enabling mod_info.json file: $e\n$st"); | |
| } | |
| } | |
| } | |
| Future<void> _disableModVariant( | |
| Variant modVariant, { | |
| bool brickModInfo = false, | |
| bool disableModInVanillaLauncher = true, | |
| required String reason, | |
| }) async { | |
| final mods = _state.getMods(); | |
| if (brickModInfo) { | |
| debugPrint(' Bricking mod_info for ${modVariant.smolId}'); | |
| } | |
| if (disableModInVanillaLauncher) { | |
| final mod = mods.firstWhere((m) => m.id == modVariant.modInfo.id); | |
| if (mod.isEnabledInGame) { | |
| await _disableModInEnabledMods(modVariant.modInfo.id); | |
| } | |
| } | |
| _state.auditLog.add('disable ${modVariant.smolId}: $reason'); | |
| } | |
| Future<void> _enableModVariant( | |
| Variant modVariant, | |
| Mod mod, { | |
| bool enableInVanillaLauncher = true, | |
| required String reason, | |
| }) async { | |
| if (mod.isEnabled(modVariant)) return; | |
| await _enableModInfoFile(modVariant); | |
| if (enableInVanillaLauncher && !mod.isEnabledInGame) { | |
| await _enableModInEnabledMods(modVariant.modInfo.id); | |
| } | |
| _state.auditLog.add('enable ${modVariant.smolId}: $reason'); | |
| } | |
| Future<void> _enableModInfoFile(Variant modVariant) async { | |
| await Future<void>.delayed(const Duration(milliseconds: 1)); | |
| } | |
| Future<void> _disableModInEnabledMods(String modId) async { | |
| _state.enabledMods[modId] = false; | |
| await Future<void>.delayed(const Duration(milliseconds: 1)); | |
| } | |
| Future<void> _enableModInEnabledMods(String modId) async { | |
| _state.enabledMods[modId] = true; | |
| await Future<void>.delayed(const Duration(milliseconds: 1)); | |
| } | |
| Future<void> _reloadModVariants({List<Variant>? onlyVariants}) async { | |
| await Future<void>.delayed(const Duration(milliseconds: 5)); | |
| } | |
| /// Mutually recursive with changeActiveModVariant_BUGGY. The recursive call | |
| /// does not execute at runtime in this test, but its presence in the call | |
| /// graph is required to trigger the crash. | |
| Future<void> _validateModDependencies({List<String>? modsToFreeze}) async { | |
| if (!_state.autoValidateDependencies) return; | |
| final modifiedModIds = modsToFreeze?.toSet() ?? {}; | |
| var numModsChangedLastLoop = 0; | |
| final gameVersion = _state.gameVersion; | |
| do { | |
| numModsChangedLastLoop = 0; | |
| final enabledMods = _state.enabledMods.entries | |
| .where((e) => e.value) | |
| .map((e) => e.key) | |
| .toList(); | |
| final allVariants = _state.allVariants; | |
| final allMods = _state.getMods(); | |
| for (final mod in allMods) { | |
| if (!mod.isEnabledInGameSync(enabledMods)) continue; | |
| if (mod.enabledVariants.length > 1) { | |
| final highestEnabled = mod.findHighestEnabledVersion; | |
| for (var value in mod.enabledVariants.where( | |
| (variant) => variant.smolId != highestEnabled?.smolId, | |
| )) { | |
| try { | |
| await _disableModVariant( | |
| value, | |
| brickModInfo: true, | |
| disableModInVanillaLauncher: false, | |
| reason: "validate ${value.smolId}", | |
| ); | |
| } catch (e, st) { | |
| debugPrint("Error disabling mod variant: $e\n$st"); | |
| } | |
| } | |
| } | |
| final enabledVariant = mod.findFirstEnabled; | |
| if (enabledVariant == null) continue; | |
| final dependenciesFound = enabledVariant.checkDependencies( | |
| allVariants, | |
| enabledMods, | |
| gameVersion, | |
| ); | |
| for (final dependencyCheck in dependenciesFound) { | |
| if (dependencyCheck.dependency.id == null) continue; | |
| if (!modifiedModIds.contains(dependencyCheck.dependency.id) && | |
| dependencyCheck.satisfiedAmount is Disabled) { | |
| final dependency = | |
| (dependencyCheck.satisfiedAmount as Disabled).modVariant; | |
| if (dependency != null) { | |
| modifiedModIds.add(mod.id); | |
| final depMod = allMods.firstWhere( | |
| (m) => m.id == dependency.modInfo.id, | |
| ); | |
| await changeActiveModVariant_BUGGY( | |
| depMod, | |
| dependency, | |
| validateDependencies: false, | |
| ); | |
| numModsChangedLastLoop++; | |
| } | |
| } else if (!modifiedModIds.contains(mod.id) && | |
| dependencyCheck.satisfiedAmount is VersionInvalid || | |
| dependencyCheck.satisfiedAmount is Missing || | |
| dependencyCheck.satisfiedAmount is Disabled) { | |
| modifiedModIds.add(mod.id); | |
| await changeActiveModVariant_BUGGY( | |
| mod, | |
| null, | |
| validateDependencies: false, | |
| ); | |
| numModsChangedLastLoop++; | |
| } | |
| } | |
| } | |
| } while (numModsChangedLastLoop > 0); | |
| } | |
| } | |
| Future<void> main(List<String> args) async { | |
| final mode = args.isNotEmpty ? args.first : 'buggy'; | |
| final manager = ModManager(StateStore()); | |
| final mod = manager._state.getMods().firstWhere((m) => m.id == 'modA'); | |
| print('mode = $mode (disable modA)'); | |
| final String result; | |
| if (mode == 'fixed') { | |
| result = await manager.changeActiveModVariant_FIXED(mod, null); | |
| } else { | |
| result = await manager.changeActiveModVariant_BUGGY(mod, null); | |
| } | |
| print('result = $result'); | |
| print('Completed without crashing.'); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Reproduce the Dart AOT codegen crash, and show the workaround surviving AOT. | |
| # | |
| # Requires Dart SDK 3.12.0 (the version with the bug). Uses `dart` from PATH by | |
| # default; if your default Dart isn't 3.12, point this at the right SDK: | |
| # $env:DART = "fvm dart"; ./run.ps1 | |
| $Dart = if ($env:DART) { $env:DART } else { "dart" } | |
| Write-Host "=== Dart version ===" | |
| Invoke-Expression "$Dart --version" | |
| Write-Host "`n=== JIT, buggy branch (expected: runs fine) ===" | |
| Invoke-Expression "$Dart run repro.dart buggy" | |
| Write-Host "JIT buggy exit code: $LASTEXITCODE" | |
| Write-Host "`n=== AOT compile ===" | |
| Invoke-Expression "$Dart compile exe repro.dart -o repro.exe" | |
| Write-Host "`n=== AOT, FIXED branch (expected: runs fine - the workaround) ===" | |
| & ".\repro.exe" fixed | |
| Write-Host "AOT fixed exit code: $LASTEXITCODE" | |
| Write-Host "`n=== AOT, BUGGY branch (expected: crash, access violation) ===" | |
| & ".\repro.exe" buggy | |
| Write-Host "AOT buggy exit code: $LASTEXITCODE" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment