| // Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:collection/collection.dart'; |
| import 'package:package_config/package_config.dart'; |
| import 'package:path/path.dart' as p; |
| import 'package:pub/pub.dart'; |
| |
| import 'core.dart'; |
| import 'progress.dart'; |
| import 'sdk.dart'; |
| |
| abstract final class ExecutableCompiler { |
| /// Suffix appended to the snapshot path to get the depfile path. |
| static const depfileExtension = '.d'; |
| |
| /// Suffix appended to the snapshot path to get the metadata file path. |
| /// |
| /// The metadata file records the configuration the snapshot was compiled |
| /// with, see [buildMetadata]. |
| static const metadataExtension = '.json'; |
| |
| /// Version of the metadata file format, bumped when the contents change in |
| /// a way older or newer SDKs cannot understand. |
| static const metadataVersion = 1; |
| |
| /// Returns the expected snapshot path in `.dart_tool/dartdev/bin/`. |
| /// |
| /// The format matches |
| /// `.dart_tool/dartdev/bin/<package>/<relativeScriptPath>.snapshot`. |
| /// The SDK version and enabled experiments are stored in the companion |
| /// metadata file (`<snapshot>.json`) rather than encoded in the filename, so |
| /// that changing SDK or experiments overwrites the snapshot instead of |
| /// leaving a stale one behind. |
| static String pathOfSnapshot({ |
| required String workspaceRoot, |
| required String packageName, |
| required String relativeScriptPath, |
| }) { |
| return p.normalize( |
| p.join( |
| workspaceRoot, |
| '.dart_tool', |
| 'dartdev', |
| 'bin', |
| packageName, |
| '$relativeScriptPath.snapshot', |
| ), |
| ); |
| } |
| |
| /// Returns the parent directory for temporary compilation directories in |
| /// `.dart_tool/dartdev/tmp/`. |
| /// |
| /// Keeping temporary directories outside `.dart_tool/dartdev/bin/` ensures |
| /// that [sweepStaleTempDirs] sweeps leftover directories across all packages |
| /// and subdirectories while never colliding with package subdirectory names. |
| static String pathOfTempDir({required String workspaceRoot}) { |
| return p.normalize(p.join(workspaceRoot, '.dart_tool', 'dartdev', 'tmp')); |
| } |
| |
| /// The version of the SDK that compiles the snapshots. |
| static String get sdkVersion => Platform.version.split(' ').first; |
| |
| /// The configuration a snapshot was compiled with. |
| /// |
| /// A cached snapshot can only be reused if this matches the configuration of |
| /// the current invocation, see [isSnapshotFresh]. |
| static Map<String, Object?> buildMetadata({ |
| required List<String> enabledExperiments, |
| }) { |
| return { |
| 'version': metadataVersion, |
| 'sdkVersion': sdkVersion, |
| 'enabledExperiments': [...enabledExperiments]..sort(), |
| }; |
| } |
| |
| /// Parses a Ninja/Makefile-style depfile generated by the compiler. |
| /// |
| /// The format is: |
| /// |
| /// target: dependency1 dependency2 ... |
| /// |
| /// Spaces in paths are escaped as `\ `, and backslashes as `\\`. |
| /// Returns `null` if [content] does not contain the `: ` separator. |
| static List<String>? parseDepfile(String content) { |
| final colonIndex = content.indexOf(': '); |
| if (colonIndex < 0) return null; |
| |
| final dependenciesString = content |
| .substring(colonIndex + 1) |
| .replaceAll(RegExp(r'[\r\n]'), ''); |
| final dependencies = <String>[]; |
| var index = 0; |
| while (index < dependenciesString.length) { |
| while (index < dependenciesString.length && |
| dependenciesString[index] == ' ') { |
| index++; |
| } |
| if (index >= dependenciesString.length) break; |
| final buffer = StringBuffer(); |
| while (index < dependenciesString.length) { |
| final char = dependenciesString[index]; |
| if (char == ' ') { |
| break; |
| } else if (char == r'\') { |
| index++; |
| if (index >= dependenciesString.length) { |
| // Malformed trailing backslash. |
| return null; |
| } |
| buffer.write(dependenciesString[index]); |
| } else { |
| buffer.write(char); |
| } |
| index++; |
| } |
| if (buffer.isNotEmpty) { |
| dependencies.add(buffer.toString()); |
| } |
| } |
| return dependencies; |
| } |
| |
| /// Checks whether [snapshotFile] is up to date with respect to its inputs. |
| /// |
| /// Returns `true` if the snapshot exists, was compiled by this SDK with |
| /// [enabledExperiments], is newer than [packageConfigFile], and is newer |
| /// than all source files recorded in the depfile. |
| static bool isSnapshotFresh({ |
| required File snapshotFile, |
| required File packageConfigFile, |
| required File depfile, |
| required File metadataFile, |
| required List<String> enabledExperiments, |
| }) { |
| try { |
| if (!snapshotFile.existsSync() || |
| !packageConfigFile.existsSync() || |
| !depfile.existsSync() || |
| !metadataFile.existsSync()) { |
| return false; |
| } |
| |
| // The snapshot is only usable if it was built with the same compiler and |
| // the same compilation options. |
| final Object? metadata; |
| try { |
| metadata = jsonDecode(metadataFile.readAsStringSync()); |
| } on FormatException { |
| return false; |
| } |
| final expectedMetadata = buildMetadata( |
| enabledExperiments: enabledExperiments, |
| ); |
| final actualExperiments = metadata is Map<String, Object?> |
| ? metadata['enabledExperiments'] |
| : null; |
| if (metadata is! Map<String, Object?> || |
| metadata['version'] != expectedMetadata['version'] || |
| metadata['sdkVersion'] != expectedMetadata['sdkVersion'] || |
| actualExperiments is! List || |
| !const ListEquality<Object?>().equals( |
| actualExperiments, |
| expectedMetadata['enabledExperiments'] as List<String>, |
| )) { |
| return false; |
| } |
| |
| final snapshotModified = snapshotFile.lastModifiedSync(); |
| if (packageConfigFile.lastModifiedSync().isAfter(snapshotModified)) { |
| return false; |
| } |
| |
| final depfileContent = depfile.readAsStringSync(); |
| final dependencies = parseDepfile(depfileContent); |
| if (dependencies == null) return false; |
| |
| for (final depPath in dependencies) { |
| final depFile = File(depPath); |
| if (!depFile.existsSync()) return false; |
| if (depFile.lastModifiedSync().isAfter(snapshotModified)) return false; |
| } |
| |
| return true; |
| } on FileSystemException { |
| return false; |
| } |
| } |
| |
| /// Compiles or returns the cached snapshot for [resolvedExecutable]. |
| /// |
| /// The snapshot is compiled with [enabledExperiments] enabled, and a cached |
| /// snapshot is only reused if it was compiled by this SDK with the same |
| /// experiments, is newer than [packageConfigPath], and is newer than all |
| /// source dependencies recorded in its depfile. |
| /// |
| /// Returns a [DartExecutableWithPackageConfig] pointing to the snapshot file. |
| static Future<DartExecutableWithPackageConfig> compile({ |
| required DartExecutableWithPackageConfig resolvedExecutable, |
| List<String> enabledExperiments = const [], |
| bool quiet = false, |
| }) async { |
| final executablePath = resolvedExecutable.executable; |
| final packageConfigPath = resolvedExecutable.packageConfig; |
| if (packageConfigPath == null) { |
| // Direct file with no package config. Not snapshotted. |
| return resolvedExecutable; |
| } |
| |
| final packageConfigAbsolute = p.absolute(packageConfigPath); |
| final packageConfig = await loadPackageConfigUri( |
| p.toUri(packageConfigAbsolute), |
| ); |
| |
| final executableAbsolute = p.absolute(executablePath); |
| final package = packageConfig.packageOf(p.toUri(executableAbsolute)); |
| if (package == null) { |
| // Could not determine package. Return resolved executable as-is. |
| return resolvedExecutable; |
| } |
| |
| final packageRootPath = p.fromUri(package.root); |
| if (!p.isWithin(packageRootPath, executableAbsolute)) { |
| return resolvedExecutable; |
| } |
| |
| final relativeScriptPath = p.relative( |
| executableAbsolute, |
| from: packageRootPath, |
| ); |
| |
| // Determine workspace root from .dart_tool/package_config.json |
| final workspaceRoot = p.dirname(p.dirname(packageConfigAbsolute)); |
| final snapshotPath = pathOfSnapshot( |
| workspaceRoot: workspaceRoot, |
| packageName: package.name, |
| relativeScriptPath: relativeScriptPath, |
| ); |
| |
| final snapshotFile = File(snapshotPath); |
| final packageConfigFile = File(packageConfigAbsolute); |
| final depfile = File('$snapshotPath$depfileExtension'); |
| final metadataFile = File('$snapshotPath$metadataExtension'); |
| |
| if (!isSnapshotFresh( |
| snapshotFile: snapshotFile, |
| packageConfigFile: packageConfigFile, |
| depfile: depfile, |
| metadataFile: metadataFile, |
| enabledExperiments: enabledExperiments, |
| )) { |
| final displayName = |
| '${package.name}:${p.basenameWithoutExtension(executablePath)}'; |
| await _compileToSnapshot( |
| executablePath: executableAbsolute, |
| displayName: displayName, |
| outputPath: snapshotPath, |
| tempParentPath: pathOfTempDir(workspaceRoot: workspaceRoot), |
| packageConfigPath: packageConfigAbsolute, |
| enabledExperiments: enabledExperiments, |
| quiet: quiet, |
| ); |
| } |
| |
| return DartExecutableWithPackageConfig( |
| executable: _relativeOrAbsolute(snapshotPath, p.current), |
| packageConfig: packageConfigPath, |
| ); |
| } |
| |
| /// Prefix of the temporary directories compilation output is written to. |
| static const _tempDirPrefix = 'tmp'; |
| |
| /// Age at which a leftover temporary directory is considered abandoned. |
| /// |
| /// Temporary directories are removed when compilation finishes, but they |
| /// survive if the process is killed before that (for example with Ctrl+C). |
| /// The threshold is generous so that a temporary directory belonging to a |
| /// slow compilation in another process is never removed. |
| static const defaultStaleTempDirAge = Duration(hours: 1); |
| |
| /// Removes temporary directories in [tempParentDir] that were left behind by |
| /// compilations interrupted before they could clean up after themselves. |
| /// |
| /// A temporary directory is only removed if it has not been modified for |
| /// [maxAge], so that directories in use by a concurrent compilation are |
| /// left alone. |
| /// |
| /// This is best-effort: failures are ignored, as another process may be |
| /// using the directory, or we may not be allowed to delete it. |
| static void sweepStaleTempDirs( |
| Directory tempParentDir, { |
| Duration maxAge = defaultStaleTempDirAge, |
| }) { |
| final now = DateTime.now(); |
| final List<FileSystemEntity> entities; |
| try { |
| entities = tempParentDir.listSync(followLinks: false); |
| } on FileSystemException { |
| return; |
| } |
| for (final entity in entities) { |
| if (entity is! Directory) continue; |
| if (!p.basename(entity.path).startsWith(_tempDirPrefix)) continue; |
| try { |
| if (now.difference(entity.statSync().modified) < maxAge) continue; |
| entity.deleteSync(recursive: true); |
| } on FileSystemException { |
| // Ignore. |
| } |
| } |
| } |
| |
| static Future<void> _compileToSnapshot({ |
| required String executablePath, |
| required String displayName, |
| required String outputPath, |
| required String tempParentPath, |
| required String packageConfigPath, |
| required List<String> enabledExperiments, |
| bool quiet = false, |
| }) async { |
| final outputDir = Directory(p.dirname(outputPath)); |
| if (!outputDir.existsSync()) { |
| outputDir.createSync(recursive: true); |
| } |
| final tempParentDir = Directory(tempParentPath); |
| if (!tempParentDir.existsSync()) { |
| tempParentDir.createSync(recursive: true); |
| } |
| |
| sweepStaleTempDirs(tempParentDir); |
| final incrementalDillPath = '$outputPath.incremental'; |
| final tempDir = tempParentDir.createTempSync(_tempDirPrefix); |
| final tempDill = p.join(tempDir.path, 'out.dill'); |
| final tempDepfile = '$tempDill$depfileExtension'; |
| final tempMetadataFile = '$tempDill$metadataExtension'; |
| final depfilePath = '$outputPath$depfileExtension'; |
| final metadataPath = '$outputPath$metadataExtension'; |
| |
| // If an existing snapshot or incremental dill is present, copy it to a |
| // temporary file and pass it as `--initialize-from-dill` to speed up |
| // subsequent compilations without holding a file lock on the destination. |
| final outputFile = File(outputPath); |
| final depfile = File(depfilePath); |
| final metadataFile = File(metadataPath); |
| final incrementalFile = File(incrementalDillPath); |
| String? initializeFromDill; |
| final sourceDill = outputFile.existsSync() |
| ? outputFile |
| : (incrementalFile.existsSync() ? incrementalFile : null); |
| if (sourceDill != null) { |
| final tempInitDill = p.join(tempDir.path, 'init.dill'); |
| try { |
| sourceDill.copySync(tempInitDill); |
| initializeFromDill = tempInitDill; |
| } on FileSystemException { |
| // Ignore: fall back to compiling from scratch. |
| } |
| } |
| |
| try { |
| final compilerSnapshot = sdk.frontendServerAotSnapshot; |
| final aotRuntime = sdk.dartAotRuntime; |
| final vmPlatformDill = sdk.vmPlatformDill; |
| final sdkRoot = p.canonicalize(sdk.sdkPath); |
| |
| if (!checkArtifactExists(compilerSnapshot, logError: false) || |
| !checkArtifactExists(aotRuntime, logError: false) || |
| !checkArtifactExists(vmPlatformDill, logError: false)) { |
| throw CompilationException( |
| 'Missing compiler snapshot, AOT runtime, or platform dill in Dart SDK. ' |
| 'Have you built the full Dart SDK?', |
| ); |
| } |
| |
| Future<void> runCompiler() async { |
| final compileArgs = <String>[ |
| compilerSnapshot, |
| '--sdk-root=$sdkRoot', |
| '--platform=${p.toUri(vmPlatformDill)}', |
| '--target=vm', |
| '--packages=$packageConfigPath', |
| for (final experiment in enabledExperiments) |
| '--enable-experiment=$experiment', |
| if (initializeFromDill != null) |
| '--initialize-from-dill=$initializeFromDill', |
| '--output-dill=$tempDill', |
| '--depfile=$tempDepfile', |
| '--incremental', |
| '--no-print-incremental-dependencies', |
| executablePath, |
| ]; |
| |
| final ProcessResult result; |
| try { |
| result = await Process.run(aotRuntime, compileArgs); |
| } on ProcessException catch (e) { |
| throw CompilationException( |
| 'Failed to start frontend compiler process ($aotRuntime): $e', |
| ); |
| } |
| |
| if (result.exitCode == 0) { |
| final tempFile = File(tempDill); |
| if (!tempFile.existsSync() || !File(tempDepfile).existsSync()) { |
| throw CompilationException( |
| 'Compilation did not produce expected output files at `$tempDill`.', |
| ); |
| } |
| File(tempMetadataFile).writeAsStringSync( |
| jsonEncode(buildMetadata(enabledExperiments: enabledExperiments)), |
| ); |
| // Delete any pre-existing metadata file before publishing the new |
| // snapshot so that an interrupted rebuild cannot leave the new |
| // snapshot paired with stale metadata. |
| _tryDelete(metadataFile); |
| await _renameFile(tempDill, outputPath); |
| await _renameFile(tempDepfile, depfilePath); |
| // Published last: without it the snapshot is considered stale, so a |
| // crash in the middle of publishing cannot result in a snapshot |
| // being reused with the wrong configuration. |
| await _renameFile(tempMetadataFile, metadataPath); |
| _tryDelete(incrementalFile); |
| } else { |
| final tempFile = File(tempDill); |
| if (tempFile.existsSync()) { |
| await _renameFile(tempDill, incrementalDillPath); |
| } |
| for (final file in [outputFile, depfile, metadataFile]) { |
| _tryDelete(file); |
| } |
| final errorLines = _extractCompilerErrorMessages( |
| '${result.stdout}\n${result.stderr}', |
| ); |
| throw CompilationException( |
| '${ansi.yellow}Failed to build $displayName:${ansi.none}\n$errorLines', |
| ); |
| } |
| } |
| |
| try { |
| if (!quiet && stderr.hasTerminal) { |
| await progress( |
| 'Building package executable', |
| runCompiler, |
| progressUpdatesOnStderr: true, |
| ); |
| log.stderr('Built ${ansi.bold}$displayName${ansi.none}.'); |
| } else { |
| await runCompiler(); |
| } |
| } on CompilationException { |
| rethrow; |
| } catch (e) { |
| throw CompilationException( |
| '${ansi.yellow}Failed to build $displayName:${ansi.none}\n$e', |
| ); |
| } |
| } finally { |
| if (tempDir.existsSync()) { |
| try { |
| tempDir.deleteSync(recursive: true); |
| } on FileSystemException { |
| // Ignore. |
| } |
| } |
| } |
| } |
| |
| static void _tryDelete(File file) { |
| try { |
| if (file.existsSync()) file.deleteSync(); |
| } on FileSystemException { |
| // Ignore. |
| } |
| } |
| |
| static String _extractCompilerErrorMessages(String output) { |
| String? boundaryKey; |
| final lines = <String>[]; |
| for (final rawLine in LineSplitter.split(output)) { |
| final trimmed = rawLine.trim(); |
| if (boundaryKey == null && trimmed.startsWith('result ')) { |
| boundaryKey = trimmed.substring('result '.length).trim(); |
| continue; |
| } |
| if (boundaryKey != null && |
| (trimmed == boundaryKey || trimmed.startsWith('$boundaryKey '))) { |
| continue; |
| } |
| lines.add(rawLine); |
| } |
| return lines.join('\n').trim(); |
| } |
| |
| static String _relativeOrAbsolute(String path, String from) { |
| try { |
| return p.normalize(p.relative(path, from: from)); |
| } on p.PathException { |
| return p.normalize(path); |
| } |
| } |
| |
| /// Number of times replacing a locked file is retried on Windows. |
| static const _windowsRenameAttempts = 10; |
| |
| static Future<void> _renameFile(String from, String to) async { |
| final fromFile = File(from); |
| if (!fromFile.existsSync()) return; |
| final toFile = File(to); |
| |
| if (!Platform.isWindows) { |
| fromFile.renameSync(to); |
| return; |
| } |
| |
| // On Windows the destination can be transiently locked (by antivirus, or |
| // by another process executing the snapshot), so retry for a short while. |
| for (var attempt = 0; attempt < _windowsRenameAttempts; attempt++) { |
| try { |
| _tryDelete(toFile); |
| fromFile.renameSync(to); |
| return; |
| } on FileSystemException { |
| await Future.delayed(const Duration(milliseconds: 20)); |
| } |
| } |
| |
| // Still locked. Copying over the destination is more likely to succeed |
| // than replacing it, as it does not require removing the locked file. |
| fromFile.copySync(to); |
| _tryDelete(fromFile); |
| } |
| } |
| |
| final class CompilationException implements Exception { |
| final String message; |
| CompilationException(this.message); |
| |
| @override |
| String toString() => message; |
| } |