diff --git a/build_runner/CHANGELOG.md b/build_runner/CHANGELOG.md index 7af14c5a0b..3198bfa5a7 100644 --- a/build_runner/CHANGELOG.md +++ b/build_runner/CHANGELOG.md @@ -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 diff --git a/build_runner/lib/src/bootstrap/aot_compiler.dart b/build_runner/lib/src/bootstrap/aot_compiler.dart index dba6354d9f..f8d233153c 100644 --- a/build_runner/lib/src/bootstrap/aot_compiler.dart +++ b/build_runner/lib/src/bootstrap/aot_compiler.dart @@ -45,6 +45,7 @@ class AotCompiler implements Compiler { @override Future compile({Iterable? experiments}) async { + await _outputDepfile.updateStamp(); final dart = Platform.resolvedExecutable; final result = await ParentProcess.run(dart, [ 'compile', diff --git a/build_runner/lib/src/bootstrap/depfile.dart b/build_runner/lib/src/bootstrap/depfile.dart index 8268a85cbf..a8840eba8b 100644 --- a/build_runner/lib/src/bootstrap/depfile.dart +++ b/build_runner/lib/src/bootstrap/depfile.dart @@ -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=`. /// /// It contains the output path followed by a colon then a space-separated list @@ -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. @@ -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 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() { @@ -94,9 +130,13 @@ 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; } @@ -104,11 +144,12 @@ class Depfile { String _digestPaths() { final digestSink = AccumulatorSink(); 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]); } diff --git a/build_runner/lib/src/bootstrap/high_resolution_mtime.dart b/build_runner/lib/src/bootstrap/high_resolution_mtime.dart new file mode 100644 index 0000000000..f3b19210df --- /dev/null +++ b/build_runner/lib/src/bootstrap/high_resolution_mtime.dart @@ -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 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 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.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; +} diff --git a/build_runner/lib/src/bootstrap/kernel_compiler.dart b/build_runner/lib/src/bootstrap/kernel_compiler.dart index 28d68f3e0f..a760d89186 100644 --- a/build_runner/lib/src/bootstrap/kernel_compiler.dart +++ b/build_runner/lib/src/bootstrap/kernel_compiler.dart @@ -45,6 +45,7 @@ class KernelCompiler implements Compiler { @override Future compile({Iterable? experiments}) async { + await _outputDepfile.updateStamp(); final dart = Platform.resolvedExecutable; final result = await ParentProcess.run(dart, [ 'compile', diff --git a/build_runner/lib/src/bootstrap/powershell.dart b/build_runner/lib/src/bootstrap/powershell.dart new file mode 100644 index 0000000000..e43b5543cf --- /dev/null +++ b/build_runner/lib/src/bootstrap/powershell.dart @@ -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 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', + ]; +} diff --git a/build_runner/lib/src/bootstrap/processes.dart b/build_runner/lib/src/bootstrap/processes.dart index 09fe51d273..9dc237d85c 100644 --- a/build_runner/lib/src/bootstrap/processes.dart +++ b/build_runner/lib/src/bootstrap/processes.dart @@ -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. /// @@ -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', ]); diff --git a/build_runner/test/bootstrap/depfile_test.dart b/build_runner/test/bootstrap/depfile_test.dart index da19504aba..26f2a96e8a 100644 --- a/build_runner/test/bootstrap/depfile_test.dart +++ b/build_runner/test/bootstrap/depfile_test.dart @@ -2,7 +2,10 @@ // 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:build_runner/src/bootstrap/depfile.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { @@ -20,5 +23,70 @@ void main() { ['input1', 'input2', 'input with space', 'input4', r'path\input5'], ); }); + + group('checkFreshness', () { + late Directory tempDir; + late File outputFile; + late File depsFile; + late File digestFile; + late File stampFile; + late File input1; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('depfile_test'); + outputFile = File(p.join(tempDir.path, 'output.txt')) + ..writeAsStringSync('output'); + input1 = File(p.join(tempDir.path, 'input1.txt')) + ..writeAsStringSync('input1'); + + depsFile = File(p.join(tempDir.path, 'deps.d')) + ..writeAsStringSync('${outputFile.path}: ${input1.path}\n'); + + digestFile = File(p.join(tempDir.path, 'deps.digest')); + stampFile = File('${digestFile.path}.stamp'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + test('not fresh if input modified between stamp and digest', () async { + stampFile.writeAsStringSync(''); + await Future.delayed(const Duration(seconds: 2)); + input1.writeAsStringSync('input1'); + await Future.delayed(const Duration(seconds: 2)); + final depfile = Depfile( + outputPath: outputFile.path, + depfilePath: depsFile.path, + digestPath: digestFile.path, + )..writeDigest(); + final result = depfile.checkFreshness(digestsAreFresh: false); + + // Digests are identical and would return "fresh", but mtime makes it + // not fresh. + expect(result.outputIsFresh, false); + }); + + test('checks content if modified after digest', () async { + stampFile.writeAsStringSync(''); + await Future.delayed(const Duration(seconds: 2)); + final depfile = Depfile( + outputPath: outputFile.path, + depfilePath: depsFile.path, + digestPath: digestFile.path, + )..writeDigest(); + var result = depfile.checkFreshness(digestsAreFresh: false); + await Future.delayed(const Duration(seconds: 2)); + input1.writeAsStringSync('input1'); + + // Digests are identical and modification was after digests. + expect(result.outputIsFresh, true); + + // Check again but with different content, digests no longer match. + input1.writeAsStringSync('input1 modified'); + result = depfile.checkFreshness(digestsAreFresh: false); + expect(result.outputIsFresh, false); + }); + }); }); } diff --git a/build_runner/test/integration_tests/builder_changes_test.dart b/build_runner/test/integration_tests/builder_changes_test.dart new file mode 100644 index 0000000000..646bc7acca --- /dev/null +++ b/build_runner/test/integration_tests/builder_changes_test.dart @@ -0,0 +1,55 @@ +// 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. + +@Tags(['integration2']) +library; + +import 'package:build_runner/src/logging/build_log.dart'; +import 'package:test/test.dart'; + +import '../common/common.dart'; + +void main() async { + test('builder changes', () async { + final pubspecs = await Pubspecs.load(); + final tester = BuildRunnerTester(pubspecs); + + tester.writeFixturePackage(FixturePackages.copyBuilder()); + tester.writePackage( + name: 'root_pkg', + dependencies: ['build_runner'], + pathDependencies: ['builder_pkg'], + files: {'web/a.txt': 'a'}, + ); + + // Start watch mode, wait for initial build. + final watch = await tester.start( + 'root_pkg', + 'dart run build_runner watch --force-jit', + ); + await watch.expect(BuildLog.successPattern); + + // Modify builder, then modify it again during the compile of the first + // modified code. + tester.update( + 'builder_pkg/lib/builder.dart', + (script) => script.replaceAll( + 'await buildStep.readAsString(buildStep.inputId)', + "'v1'", + ), + ); + await watch.expect('Starting build #2 with updated builders.'); + await watch.expect(RegExp(r'1s compiling builders/jit')); + tester.update( + 'builder_pkg/lib/builder.dart', + (script) => script.replaceAll("'v1'", "'v2'"), + ); + await watch.expect('Starting build #3 with updated builders.'); + await watch.expect(BuildLog.successPattern); + await watch.kill(); + + // Verify that the output is from the latest modified code. + expect(tester.read('root_pkg/web/a.txt.copy'), 'v2'); + }); +} diff --git a/build_runner/test/integration_tests/high_resolution_mtime_test.dart b/build_runner/test/integration_tests/high_resolution_mtime_test.dart new file mode 100644 index 0000000000..066947992b --- /dev/null +++ b/build_runner/test/integration_tests/high_resolution_mtime_test.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:build_runner/src/bootstrap/high_resolution_mtime.dart'; +import 'package:test/test.dart'; + +void main() { + group('HighResolutionMtime', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('mtime_test'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('high resolution mtime', () async { + // High res mtimes are close to SDK mtimes. + final file = File('${tempDir.path}/file')..writeAsStringSync('1'); + final dartMtime = file.lastModifiedSync().nanosecondsSinceEpoch; + final highResMtime = HighResolutionMtime.getHighResMtimeOrNull( + file.path, + )!; + expect((highResMtime - dartMtime).abs(), lessThan(1_000_000_000)); + + // Can wait for clock advance. + final stamp = File('${tempDir.path}/stamp')..writeAsStringSync('1'); + await HighResolutionMtime.waitForNewMtime(stamp.path); + final newFile = File('${tempDir.path}/new')..writeAsStringSync('2'); + final stampMtime = HighResolutionMtime.getHighResMtimeOrNull(stamp.path)!; + final newFileMtime = HighResolutionMtime.getHighResMtimeOrNull( + newFile.path, + )!; + expect(newFileMtime, greaterThan(stampMtime)); + + // Can determine "modified between". + final file1 = File('${tempDir.path}/file1')..writeAsStringSync('1'); + await HighResolutionMtime.waitForNewMtime(file1.path); + final file2 = File('${tempDir.path}/file2')..writeAsStringSync('2'); + await HighResolutionMtime.waitForNewMtime(file2.path); + final file3 = File('${tempDir.path}/file3')..writeAsStringSync('3'); + expect( + HighResolutionMtime.hasModifiedBetween( + paths: [file2.path], + startStampPath: file1.path, + endStampPath: file3.path, + ), + isTrue, + ); + expect( + HighResolutionMtime.hasModifiedBetween( + paths: [file3.path], + startStampPath: file1.path, + endStampPath: file2.path, + ), + isFalse, + ); + }); + }); +} diff --git a/build_runner/test/integration_tests/processes_test.dart b/build_runner/test/integration_tests/processes_test.dart index d43edbb132..b427f9dd49 100644 --- a/build_runner/test/integration_tests/processes_test.dart +++ b/build_runner/test/integration_tests/processes_test.dart @@ -7,6 +7,7 @@ library; import 'dart:io'; +import 'package:build_runner/src/bootstrap/powershell.dart'; import 'package:test/test.dart'; import '../common/common.dart'; @@ -153,6 +154,7 @@ void main() async { bool processIsRunning(int pid) { if (Platform.isWindows) { return Process.runSync('powershell', [ + ...Powershell.baseArgs, '-Command', 'Get-Process -Id $pid', ]).exitCode ==