Improve precompilation (#4005)

diff --git a/doc/cache_layout.md b/doc/cache_layout.md
index 5c58d36..47c6305 100644
--- a/doc/cache_layout.md
+++ b/doc/cache_layout.md
@@ -194,7 +194,6 @@
     │   └── mono_repo.dart-3.0.0-55.0.dev.snapshot
     ├── .dart_tool/
     │   └── package_config.json
-    ├── incremental
     └── pubspec.lock
 ```
 
@@ -213,9 +212,6 @@
 limitations, and we should probably rethink this). A re-activation of the
 package will delete all the existing snapshots.
 
-The `incremental` is used while compiling them. (TODO: We should probably remove
-this after successful compilation https://github.com/dart-lang/pub/issues/3896).
-
 For packages activated with `--source=path` the lockfile is special-cased to just point
 to the activated path, and `.dart_tool/package_config.json`, snapshots are
 stored in that folder.
diff --git a/doc/dart_tool_layout.md b/doc/dart_tool_layout.md
new file mode 100644
index 0000000..94f34be
--- /dev/null
+++ b/doc/dart_tool_layout.md
@@ -0,0 +1,64 @@
+# Layout of the `.dart_tool/pub` folder
+
+The pub client creates `.dart_tool/package_config.json` as described by
+[https://github.com/dart-lang/language/blob/main/accepted/2.8/language-versioning/package-config-file-v2.md].
+
+But furthermore pub can use a folder called `.dart_tool/pub` for storing
+artifacts. The organization of that folder is what this document is trying to describe.
+
+The information in this document is informational, and can be used for
+understanding the cache, but we strongly encourage all manipulation of the
+`.dart_tool/pub` folder happens though the `dart pub`/`flutter pub` commands to
+avoid relying on accidental properties that might be broken in the future.
+
+## Precompilation cache
+
+```tree
+.dart_tool/
+├── package_config.json
+├── pub
+│   ├── bin
+│   │   ├── pub
+│   │   │   └── pub.dart-3.1.0.snapshot.incremental
+│   │   └── test
+│   │       └── test.dart-3.2.0-36.0.dev.snapshot
+
+```
+
+When `dart run <package>:<executable>` is called, pub will try to find `<executable>` in
+the package `<package>` and compile it as a "dill" file (using
+`package:frontend_server_client`).
+
+The output will be stored in The dill file will be stored in
+`.dart_tool/pub/bin/<package>/<executable>.dart-<sdk-version>.snapshot`.
+
+This can be used to run the executable by invoking (done implicitly by `dart run`):
+
+```
+dart .dart_tool/pub/bin/<package>/<executable>.dart-<sdk-version>.snapshot
+```
+
+But the dill-file is also fed to the compiler for incremental compilation. This
+can in many cases greatly speed up the compilation when no change has happened.
+
+If the compilation fails, pub avoids leaving a `.snapshot` file, but instead leaves a
+`.dart_tool/pub/bin/<package>/<executable>.dart-<sdk-version>.snapshot.incremental` file.
+
+This file cannot be executed. But it can still give the benefit of incremental
+compilation when changes have happened to the code.
+
+Earlier versions of the dart sdk would put this "incremental" file in:
+
+`.dart_tool/pub/incremental/<package>/<executable>.dart-incremental.dill`.
+
+As we don't expect many of those files to linger, we don't attempt to clean them up.
+
+We use the `<sdk-version>` to enable different sdk-versions to each have their
+own snapshot, so they don't step on each others toes when you switch from one
+sdk to another. The downside is that there is no mechanism for deleting
+snapshots of old sdks. We might want change that logic.
+
+One could argue that a "snapshot", is a different thing from a "dill" file in
+Dart VM terms. But both can be invoked by the VM, and run rather quickly without
+much more pre-compilation. In the future we might want to use true "snapshots"
+for executables from immutable packages, as they don't benefit from incremental compilation.
diff --git a/lib/src/dart.dart b/lib/src/dart.dart
index 4e1c4d1..85e3095 100644
--- a/lib/src/dart.dart
+++ b/lib/src/dart.dart
@@ -96,10 +96,13 @@
 ///
 /// If the compilation succeeds it is saved to a kernel file at [outputPath].
 ///
-/// If compilation fails, the output is cached at [incrementalDillOutputPath].
+/// If compilation fails, the output is cached at "[outputPath].incremental".
 ///
-/// Whichever of [incrementalDillOutputPath] and [outputPath] already exists is
-/// used to initialize the compiler run.
+/// Whichever of "[outputPath].incremental" and [outputPath] already exists is
+/// used to initialize the compiler run. To avoid the potential for
+/// race-conditions, it is first copied to a temporary location, and atomically
+/// moved to either [outputPath] or "[outputPath].incremental" depending on the
+/// result of compilation.
 ///
 /// The [packageConfigPath] should point at the package config file to be used
 /// for `package:` uri resolution.
@@ -114,31 +117,29 @@
 /// assets map.
 Future<void> precompile({
   required String executablePath,
-  required String incrementalDillPath,
   required String name,
   required String outputPath,
   required String packageConfigPath,
   List<String> additionalSources = const [],
   String? nativeAssets,
 }) async {
-  ensureDir(p.dirname(outputPath));
-  ensureDir(p.dirname(incrementalDillPath));
-
   const platformDill = 'lib/_internal/vm_platform_strong.dill';
   final sdkRoot = p.relative(p.dirname(p.dirname(Platform.resolvedExecutable)));
   String? tempDir;
   FrontendServerClient? client;
   try {
-    tempDir = createTempDir(p.dirname(incrementalDillPath), 'tmp');
+    ensureDir(p.dirname(outputPath));
+    final incrementalDillPath = '$outputPath.incremental';
+    tempDir = createTempDir(p.dirname(outputPath), 'tmp');
     // To avoid potential races we copy the incremental data to a temporary file
     // for just this compilation.
     final temporaryIncrementalDill =
         p.join(tempDir, '${p.basename(incrementalDillPath)}.temp');
     try {
-      if (fileExists(incrementalDillPath)) {
-        copyFile(incrementalDillPath, temporaryIncrementalDill);
-      } else if (fileExists(outputPath)) {
+      if (fileExists(outputPath)) {
         copyFile(outputPath, temporaryIncrementalDill);
+      } else if (fileExists(incrementalDillPath)) {
+        copyFile(incrementalDillPath, temporaryIncrementalDill);
       }
     } on FileSystemException {
       // Not able to copy existing file, compilation will start from scratch.
@@ -172,11 +173,14 @@
       // By using rename we ensure atomicity. An external observer will either
       // see the old or the new snapshot.
       renameFile(temporaryIncrementalDill, outputPath);
+      // Any old incremental data is deleted in case we started from a file on
+      // [incrementalDillPath].
+      deleteEntry(incrementalDillPath);
     } else {
       // By using rename we ensure atomicity. An external observer will either
       // see the old or the new snapshot.
       renameFile(temporaryIncrementalDill, incrementalDillPath);
-      // If compilation failed we don't want to leave an incorrect snapshot.
+      // If compilation failed, don't leave an incorrect snapshot.
       tryDeleteEntry(outputPath);
 
       throw ApplicationException(
diff --git a/lib/src/entrypoint.dart b/lib/src/entrypoint.dart
index efba10c..b149008 100644
--- a/lib/src/entrypoint.dart
+++ b/lib/src/entrypoint.dart
@@ -222,10 +222,6 @@
   /// The path to the directory containing dependency executable snapshots.
   String get _snapshotPath => p.join(cachePath, 'bin');
 
-  /// The path to the directory containing previous dill files for incremental
-  /// builds.
-  String get _incrementalDillsPath => p.join(cachePath, 'incremental');
-
   Entrypoint._(
     this.rootDir,
     this._lockFile,
@@ -524,7 +520,6 @@
     await dart.precompile(
       executablePath: await resolveExecutable(executable),
       outputPath: pathOfExecutable(executable),
-      incrementalDillPath: incrementalDillPathOfExecutable(executable),
       packageConfigPath: packageConfigPath,
       name: '$package:${p.basenameWithoutExtension(executable.relativePath)}',
       additionalSources: additionalSources,
@@ -555,20 +550,6 @@
           );
   }
 
-  String incrementalDillPathOfExecutable(Executable executable) {
-    assert(p.isRelative(executable.relativePath));
-    return isGlobal
-        ? p.join(
-            _incrementalDillsPath,
-            '${p.basename(executable.relativePath)}.incremental.dill',
-          )
-        : p.join(
-            _incrementalDillsPath,
-            executable.package,
-            '${p.basename(executable.relativePath)}.incremental.dill',
-          );
-  }
-
   /// The absolute path of [executable] resolved relative to [this].
   Future<String> resolveExecutable(Executable executable) async {
     return p.join(
diff --git a/pubspec.yaml b/pubspec.yaml
index 4e034b8..a9d1321 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -9,6 +9,7 @@
   analyzer: ^5.1.0
   args: ^2.4.0
   async: ^2.6.1
+  checks: ^0.2.2
   cli_util: ^0.4.0
   collection: ^1.15.0
   convert: ^3.0.2
diff --git a/test/precompilation_test.dart b/test/precompilation_test.dart
new file mode 100644
index 0000000..7ed02ea
--- /dev/null
+++ b/test/precompilation_test.dart
@@ -0,0 +1,111 @@
+import 'dart:io';
+
+import 'package:checks/checks.dart';
+import 'package:pub/src/dart.dart';
+import 'package:pub/src/exceptions.dart';
+import 'package:pub/src/log.dart';
+import 'package:test/test.dart';
+
+import 'descriptor.dart';
+
+String outputPath() => '$sandbox/output/snapshot';
+String incrementalDillPath() => '${outputPath()}.incremental';
+
+// A quite big program is needed for the caching to be an actual advantage.
+FileDescriptor foo = file('foo.dart', '''
+foo() {
+  ${List.generate(500000, (index) => 'print("$index");').join('\n')}
+}
+  ''');
+
+FileDescriptor workingMain = file(
+  'main.dart',
+  '''
+import 'foo.dart';
+
+main() async {
+  foo();
+}
+''',
+);
+
+FileDescriptor brokenMain = file(
+  'main.dart',
+  '''
+import 'foo.dart';
+yadda yadda
+main() asyncc {
+  foo();
+}
+''',
+);
+
+Future<Duration> timeCompilation(
+  String executable, {
+  bool fails = false,
+}) async {
+  final s = Stopwatch()..start();
+  verbosity = Verbosity.none;
+  Future<void> compile() async {
+    await precompile(
+      executablePath: executable,
+      name: 'abc',
+      outputPath: outputPath(),
+      packageConfigPath: path('app/.dart_tool/package_config.json'),
+    );
+  }
+
+  if (fails) {
+    await check(compile()).throws<ApplicationException>();
+  } else {
+    await compile();
+  }
+  verbosity = Verbosity.normal;
+  return s.elapsed;
+}
+
+void main() {
+  test('Precompilation is much faster second time and removes old artifacts',
+      () async {
+    await dir('app', [
+      workingMain,
+      foo,
+      packageConfigFile([]),
+    ]).create();
+    final first = await timeCompilation(path('app/main.dart'));
+    check(
+      because: 'Should not leave a stray directory.',
+      File(incrementalDillPath()).existsSync(),
+    ).isFalse();
+    check(File(outputPath()).existsSync()).isTrue();
+
+    // Do a second compilation to compare the compile times, it should be much
+    // faster because it can reuse the compiled data in the dill file.
+    final second = await timeCompilation(path('app/main.dart'));
+    check(first).isGreaterThan(second * 2);
+
+    // Now create an error to test that the output is placed at a different
+    // location.
+    await dir('app', [
+      brokenMain,
+      foo,
+      packageConfigFile([]),
+    ]).create();
+    final afterErrors =
+        await timeCompilation(path('app/main.dart'), fails: true);
+    check(File(incrementalDillPath()).existsSync()).isTrue();
+    check(File(outputPath()).existsSync()).isFalse();
+    check(first).isGreaterThan(afterErrors * 2);
+
+    // Fix the error, and check that we still use the cached output to improve
+    // compile times.
+    await dir('app', [
+      workingMain,
+    ]).create();
+    final afterFix = await timeCompilation(path('app/main.dart'));
+    // The output from the failed compilation should now be gone.
+    check(File('${outputPath()}.incremental').existsSync()).isFalse();
+    check(File(outputPath()).existsSync()).isTrue();
+    check(first).isGreaterThan(afterFix * 2);
+  });
+}
diff --git a/tool/test.dart b/tool/test.dart
index d0ffe95..2eb8adc 100755
--- a/tool/test.dart
+++ b/tool/test.dart
@@ -30,16 +30,16 @@
   });
   final pubSnapshotFilename =
       path.absolute(path.join('.dart_tool', '_pub', 'pub.dart.snapshot.dart2'));
-  final pubSnapshotIncrementalFilename = '$pubSnapshotFilename.incremental';
   try {
-    stderr.writeln('Building snapshot');
+    final stopwatch = Stopwatch()..start();
+    stderr.write('Building snapshot...');
     await precompile(
       executablePath: path.join('bin', 'pub.dart'),
       outputPath: pubSnapshotFilename,
-      incrementalDillPath: pubSnapshotIncrementalFilename,
       name: 'bin/pub.dart',
       packageConfigPath: path.join('.dart_tool', 'package_config.json'),
     );
+    stderr.writeln(' (${stopwatch.elapsed.inMilliseconds}ms)');
     testProcess = await Process.start(
       Platform.resolvedExecutable,
       ['run', 'test', ...args],
@@ -51,11 +51,6 @@
     stderr.writeln('Failed building snapshot: $e');
     exitCode = 1;
   } finally {
-    try {
-      await File(pubSnapshotFilename).delete();
-      await sub.cancel();
-    } on Exception {
-      // snapshot didn't exist.
-    }
+    await sub.cancel();
   }
 }