From 05ff8dea615ffd4ac9e269cef96f9dca250c855e Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Wed, 29 Jul 2026 23:07:59 -0700 Subject: [PATCH] Track impellerc and an engine stamp as shader bundle build inputs Shader bundles are tied to the engine build that supplied impellerc, but the hook's declared inputs only covered the manifest and shader sources, so engine changes within a Dart SDK version left stale bundles in place, and the pub-cache output path let projects on different SDKs overwrite each other's bundle undetected. Declare the impellerc binary as a build dependency and write a content-addressed engine stamp beside each bundle so either change reruns the hook. --- CHANGELOG.md | 5 ++++ lib/build.dart | 69 ++++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 3 +- test/build_test.dart | 62 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3cf198..2389fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.5.2 + +* Track the `impellerc` binary as a build input, so bundles rebuild whenever the engine artifacts change. +* Write an engine stamp beside each bundle, so a shared pub-cache bundle rewritten by another project (or Flutter SDK) triggers a rebuild instead of shipping a wrong-engine bundle. + ## 0.5.1 * Added a `glesLanguageVersion` option to `buildShaderBundleJson` (and diff --git a/lib/build.dart b/lib/build.dart index 722f484..998daf7 100644 --- a/lib/build.dart +++ b/lib/build.dart @@ -1,6 +1,7 @@ import 'dart:convert' as convert; import 'dart:io'; +import 'package:crypto/crypto.dart' as crypto; import 'package:data_assets/data_assets.dart'; import 'package:hooks/hooks.dart'; @@ -200,6 +201,13 @@ Future _buildShaderBundleJson({ await depfile.delete(); } + // The produced bundle is tied to the engine build that supplied `impellerc` + // (the compiler ships in lockstep with the engine artifacts), so the + // compiler binary is a build input like any shader source. Content-hashing + // it reruns this hook on engine changes that the Dart SDK version alone + // does not reveal. + dependencies.add(impellercExec); + // Declare the collected dependencies, excluding anything under the package's // `build/` output directory. Generated shaders (for example synthesized from // another format and written into `build/`) are rewritten on every build, so @@ -213,6 +221,67 @@ Future _buildShaderBundleJson({ (uri) => !uri.toFilePath(windows: false).startsWith(buildDirectory), ), ); + + // The output path is shared through the pub cache, so another project + // (possibly on a different Flutter SDK) can rewrite the same bundle file + // with a different `impellerc`. The stamp records which compiler last wrote + // the bundle; declaring it as a dependency turns such a rewrite into a hash + // mismatch that reruns this hook. Declared after the `build/` filter above + // because the stamp deliberately lives next to the bundle. + final stampUri = engineStampUriForBundle(outputBundleFilePath); + await writeEngineStampIfChanged( + stampUri: stampUri, + impellercExec: impellercExec, + glesLanguageVersion: glesLanguageVersion, + ); + buildOutput.dependencies.add(stampUri); +} + +/// The engine-stamp path for a shader bundle at [outputBundleFilePath]. +Uri engineStampUriForBundle(Uri outputBundleFilePath) => + Uri.file('${outputBundleFilePath.toFilePath()}.engine_stamp.json'); + +/// JSON stamp identifying the compiler configuration a bundle was built with. +Future engineStampJson({ + required Uri impellercExec, + int? glesLanguageVersion, +}) async { + final digest = crypto.sha256.convert( + await File.fromUri(impellercExec).readAsBytes(), + ); + return convert.json.encode({ + 'impellerc_sha256': digest.toString(), + 'gles_language_version': ?glesLanguageVersion, + }); +} + +/// Writes the engine stamp for [impellercExec] to [stampUri], skipping the +/// write when the on-disk content already matches. +/// +/// Skipping matters because hook runners flag any tracked file modified +/// during the build and would otherwise rerun the hook on every build. For +/// the same reason a genuine rewrite backdates the stamp's mtime; change +/// detection is content-hash based, so the mtime carries no information. +Future writeEngineStampIfChanged({ + required Uri stampUri, + required Uri impellercExec, + int? glesLanguageVersion, +}) async { + final contents = await engineStampJson( + impellercExec: impellercExec, + glesLanguageVersion: glesLanguageVersion, + ); + final file = File.fromUri(stampUri); + if (await file.exists() && await file.readAsString() == contents) { + return; + } + await file.writeAsString(contents); + try { + file.setLastModifiedSync(DateTime.utc(2000)); + } on FileSystemException { + // Backdating is best-effort; without it the runner reruns the hook once + // more before the stamp settles. + } } String _impellerCHelpText(Uri impellercExec) { diff --git a/pubspec.yaml b/pubspec.yaml index 988f27d..6aa0fb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_gpu_shaders description: 'Build tools for Flutter GPU shader bundles/libraries.' -version: 0.5.1 +version: 0.5.2 homepage: https://github.com/bdero/flutter_gpu_shaders environment: @@ -8,6 +8,7 @@ environment: flutter: '>=1.17.0' dependencies: + crypto: ^3.0.3 data_assets: ^0.20.0 flutter: sdk: flutter diff --git a/test/build_test.dart b/test/build_test.dart index 804b769..03a8590 100644 --- a/test/build_test.dart +++ b/test/build_test.dart @@ -1,6 +1,7 @@ import 'dart:convert' as convert; import 'dart:io'; +import 'package:crypto/crypto.dart' as crypto; import 'package:data_assets/data_assets.dart'; import 'package:flutter_gpu_shaders/build.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -311,6 +312,67 @@ void main() { expect(asset.package, 'example_app'); }); }); + + group('engine stamp', () { + late Directory temp; + late Uri impellerc; + late Uri stamp; + + setUp(() { + temp = Directory.systemTemp.createTempSync('flutter_gpu_shaders_stamp'); + impellerc = temp.uri.resolve('impellerc'); + File.fromUri(impellerc).writeAsBytesSync([1, 2, 3, 4]); + stamp = engineStampUriForBundle(temp.uri.resolve('base.shaderbundle')); + }); + + tearDown(() => temp.deleteSync(recursive: true)); + + test('stamp content hashes the compiler binary', () async { + final contents = await engineStampJson( + impellercExec: impellerc, + glesLanguageVersion: 300, + ); + final decoded = convert.json.decode(contents) as Map; + expect( + decoded['impellerc_sha256'], + crypto.sha256.convert([1, 2, 3, 4]).toString(), + ); + expect(decoded['gles_language_version'], 300); + }); + + test('write is skipped when the content is unchanged', () async { + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + final file = File.fromUri(stamp); + // The fresh write is backdated so hook runners never see the stamp as + // modified during the build. + expect(file.lastModifiedSync().toUtc(), DateTime.utc(2000)); + file.setLastModifiedSync(DateTime.utc(2010)); + + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + expect(file.lastModifiedSync().toUtc(), DateTime.utc(2010)); + }); + + test('a changed compiler rewrites the stamp', () async { + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + final before = File.fromUri(stamp).readAsStringSync(); + + File.fromUri(impellerc).writeAsBytesSync([5, 6, 7, 8]); + await writeEngineStampIfChanged( + stampUri: stamp, + impellercExec: impellerc, + ); + expect(File.fromUri(stamp).readAsStringSync(), isNot(before)); + }); + }); } BuildInput _buildInput({required bool buildDataAssets}) {