Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build_runner/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- Bug fix: fix `dart run build_runner test` to correctly pass arguments after
`--` to the test process.
- Bug fix: fix crash if a resolved Dart source contains invalid utf8.
- Bug fix: fix incorrect output when builder code changes during builder
compile.
- Require Dart 3.8.0.

## 2.15.0
Expand Down
1 change: 1 addition & 0 deletions build_runner/lib/src/bootstrap/aot_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class AotCompiler implements Compiler {

@override
Future<CompileResult> compile({Iterable<String>? experiments}) async {
await _outputDepfile.updateStamp();
final dart = Platform.resolvedExecutable;
final result = await ParentProcess.run(dart, [
'compile',
Expand Down
69 changes: 55 additions & 14 deletions build_runner/lib/src/bootstrap/depfile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:meta/meta.dart';

import 'high_resolution_mtime.dart';

/// A depfile, as written by `dart compile kernel ... --depfile=<output>`.
///
/// It contains the output path followed by a colon then a space-separated list
Expand Down Expand Up @@ -36,23 +38,44 @@ class Depfile {
/// will be re-used from disk if possible instead of recomputed.
FreshnessResult checkFreshness({required bool digestsAreFresh}) {
final outputFile = File(outputPath);
if (!outputFile.existsSync()) return FreshnessResult(outputIsFresh: false);
final depsFile = File(depfilePath);
if (!depsFile.existsSync()) return FreshnessResult(outputIsFresh: false);
final digestFile = File(digestPath);
if (!digestFile.existsSync()) return FreshnessResult(outputIsFresh: false);

if (!outputFile.existsSync() ||
!depsFile.existsSync() ||
!digestFile.existsSync()) {
return FreshnessResult(outputIsFresh: false);
}

_depfilePaths ??= _readPaths();

// Digests are computed after the compile. If any file was modified during
// the compile then we can't know if the output is fresh, because we can't
// know if the compiler used the original or modified file. Check for that
// case by comparing timestamps.
final stampPath = '$digestPath.stamp';
if (File(stampPath).existsSync()) {
if (HighResolutionMtime.hasModifiedBetween(
paths: _depfilePaths!,
startStampPath: stampPath,
endStampPath: digestPath,
)) {
return FreshnessResult(outputIsFresh: false);
}
}

final digests = digestFile.readAsStringSync();

if (digestsAreFresh) {
_depfilePaths ??= _readPaths();
return FreshnessResult(outputIsFresh: true, digest: digests);
}

_depfilePaths = _readPaths();
final expectedDigests = _digestPaths();
return digests == expectedDigests
? FreshnessResult(outputIsFresh: true, digest: digests)
: FreshnessResult(outputIsFresh: false);
if (digests == expectedDigests) {
return FreshnessResult(outputIsFresh: true, digest: digests);
} else {
return FreshnessResult(outputIsFresh: false);
}
}

/// Checks whether [path] is mentioned in the depfile.
Expand All @@ -61,6 +84,19 @@ class Depfile {
/// [writeDigest], throws if neither was called.
bool isDependency(String path) => _depfilePaths!.contains(path);

/// Updates the compilation stamp to the current time then waits until the
/// filesystem clock has advanced.
///
/// This ensures that the next write by any tool will have a distinguishably
/// later timestamp.
Future<void> updateStamp() async {
final stampPath = '$digestPath.stamp';
File(stampPath)
..createSync(recursive: true)
..writeAsBytesSync(const []);
await HighResolutionMtime.waitForNewMtime(stampPath);
}

/// Writes a digest of all input files mentioned in [depfilePath] to
/// [digestPath].
void writeDigest() {
Expand Down Expand Up @@ -94,21 +130,26 @@ class Depfile {
final item = items[i];
final path = item.replaceAll('\u0000', ' ');
// File ends in a newline.
result.add(
i == items.length - 1 ? path.substring(0, path.length - 1) : path,
);
var parsedPath = i == items.length - 1
? path.substring(0, path.length - 1)
: path;
if (parsedPath.startsWith('file://')) {
parsedPath = Uri.parse(parsedPath).toFilePath();
}
result.add(parsedPath);
}
return result;
}

String _digestPaths() {
final digestSink = AccumulatorSink<Digest>();
final result = md5.startChunkedConversion(digestSink);
for (final dep in _depfilePaths!) {
final file = File(dep);
for (final path in _depfilePaths!) {
final file = File(path);
if (file.existsSync()) {
result.add([1]);
result.add(File(dep).readAsBytesSync());
final bytes = file.readAsBytesSync();
result.add(bytes);
} else {
result.add([0]);
}
Expand Down
162 changes: 162 additions & 0 deletions build_runner/lib/src/bootstrap/high_resolution_mtime.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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:io';

import 'package:meta/meta.dart';

import 'powershell.dart';

/// Platform-specific code to get high resolution mtimes.
///
/// TODO(davidmorgan): remove when build_runner's min SDK version includes
/// https://dart-review.googlesource.com/c/sdk/+/507341 so that the SDK provides
/// fine enough mtimes directly.
class HighResolutionMtime {
/// Whether any file in [paths] exists and has a modification time strictly
/// greater than [startStampPath] and less than or equal to [endStampPath],
/// as closely as can be determined.
static bool hasModifiedBetween({
required Iterable<String> paths,
required String startStampPath,
required String endStampPath,
}) {
final startDart = File(startStampPath).lastModifiedSync();
final endDart = File(endStampPath).lastModifiedSync();

NanosecondsSinceEpoch? startHighRes;
NanosecondsSinceEpoch? endHighRes;

for (final path in paths) {
final file = File(path);
if (!file.existsSync()) continue;

/// Distinguish using SDK mtimes if possible.
final dartMtime = file.lastModifiedSync();
if (dartMtime.compareTo(startDart) < 0) continue;
if (dartMtime.compareTo(endDart) > 0) continue;

// Can't distinguish, try to do so using high-res mtimes.
startHighRes ??= getHighResMtimeOrNull(startStampPath);
endHighRes ??= getHighResMtimeOrNull(endStampPath);
final pathHighRes = getHighResMtimeOrNull(path);

// Failed to get high-res mtimes, return `true`.
if (startHighRes == null || endHighRes == null || pathHighRes == null) {
return true;
}

if (pathHighRes > startHighRes && pathHighRes <= endHighRes) {
return true;
}
}
return false;
}

static NanosecondsSinceEpoch? _linuxHighResMtime(String filePath) {
try {
final res = Process.runSync('stat', ['-c', '%.9Y', filePath]);
if (res.exitCode == 0) {
return _parseStatTime(res.stdout.toString().trim());
}
} catch (_) {}
return null;
}

static NanosecondsSinceEpoch? _macHighResMtime(String filePath) {
try {
final res = Process.runSync('stat', ['-f', '%.9Fm', filePath]);
if (res.exitCode == 0) {
return _parseStatTime(res.stdout.toString().trim());
}
} catch (_) {}
return null;
}

static NanosecondsSinceEpoch? _windowsHighResMtime(String filePath) {
try {
final res = Process.runSync('powershell', [
...Powershell.baseArgs,
'-Command',
'(Get-Item "$filePath").LastWriteTimeUtc.Ticks',
]);
if (res.exitCode == 0) {
final ticks = int.tryParse(res.stdout.toString().trim());
if (ticks != null) {
return _windowsTicksToNanosecondsSinceEpoch(ticks);
}
}
} catch (_) {}
return null;
}

static NanosecondsSinceEpoch _windowsTicksToNanosecondsSinceEpoch(int ticks) {
// Convert ticks epoch to Unixepoch by subtracting
// `DateTime.UnixEpoch.Ticks`, then multiply by 100 as ticks are
//100-nanosecond units.
//
// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/DateTime.cs
const unixEpochTicks = 719_162 * 864_000_000_000;
return (ticks - unixEpochTicks) * 100;
}

static NanosecondsSinceEpoch _parseStatTime(String statOutput) {
final parts = statOutput.split('.');
if (parts.length != 2 || parts[1].length != 9) {
throw const FormatException();
}
final seconds = int.parse(parts[0]);
final nanoseconds = int.parse(parts[1]);
return seconds * 1_000_000_000 + nanoseconds;
}

@visibleForTesting
static NanosecondsSinceEpoch? getHighResMtimeOrNull(String filePath) {
NanosecondsSinceEpoch? mtime;
if (Platform.isLinux) mtime = _linuxHighResMtime(filePath);
if (Platform.isMacOS) mtime = _macHighResMtime(filePath);
if (Platform.isWindows) mtime = _windowsHighResMtime(filePath);
return mtime;
}

static NanosecondsSinceEpoch _getHighResMtimeWithFallback(String filePath) {
return getHighResMtimeOrNull(filePath) ??
File(filePath).lastModifiedSync().nanosecondsSinceEpoch;
}

/// Waits until the filesystem modification time has advanced past the
/// modification time of [path].
///
/// This ensures that any subsequent file modifications will receive a
/// strictly greater modification time.
static Future<void> waitForNewMtime(String path) async {
final stampTime = _getHighResMtimeWithFallback(path);
final tempFile = File('$path.tmp');
var delayMs = 1;
try {
// Loop waiting for tmp modified time to be after `path` modified time.
// Exponential backoff to allow fast success but not write too often on
// slow success.
while (true) {
tempFile.writeAsBytesSync(const []);
if (_getHighResMtimeWithFallback(tempFile.path) > stampTime) {
break;
}
await Future<void>.delayed(Duration(milliseconds: delayMs));
delayMs = (delayMs * 2).clamp(1, 50);
}
} finally {
if (tempFile.existsSync()) {
tempFile.deleteSync();
}
}
}
}

typedef NanosecondsSinceEpoch = int;

extension DateTimeExtension on DateTime {
NanosecondsSinceEpoch get nanosecondsSinceEpoch =>
microsecondsSinceEpoch * 1_000;
}
1 change: 1 addition & 0 deletions build_runner/lib/src/bootstrap/kernel_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class KernelCompiler implements Compiler {

@override
Future<CompileResult> compile({Iterable<String>? experiments}) async {
await _outputDepfile.updateStamp();
final dart = Platform.resolvedExecutable;
final result = await ParentProcess.run(dart, [
'compile',
Expand Down
17 changes: 17 additions & 0 deletions build_runner/lib/src/bootstrap/powershell.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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.

/// Windows powershell.
class Powershell {
static List<String> baseArgs = [
// Flutter uses "-ExecutionPolicy Bypass" when it uses powershell for
// its "update Dart" script, do the same.
'-ExecutionPolicy',
'Bypass',
// No user profile: faster startup, avoid customizations.
'-NoProfile',
// Error instead of hang on user prompt.
'-NonInteractive',
];
}
6 changes: 2 additions & 4 deletions build_runner/lib/src/bootstrap/processes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import '../build_plan/builder_factories.dart';
import '../build_runner.dart';
import '../logging/build_log.dart';
import 'build_process_state.dart';
import 'powershell.dart';

/// Methods for causing a child process to run and do work.
///
Expand Down Expand Up @@ -184,10 +185,7 @@ class ParentProcess {
try {
if (Platform.isWindows) {
return await Process.start('powershell', [
// Flutter uses "-ExecutionPolicy Bypass" when it uses powershell for
// its "update Dart" script, do the same.
'-ExecutionPolicy',
'Bypass',
...Powershell.baseArgs,
'-Command',
'Wait-Process -Id $parentPid; Stop-Process -Id $childPid -Force',
]);
Expand Down
Loading
Loading