1
0
mirror of https://github.com/dart-lang/sdk synced 2024-07-05 09:20:04 +00:00

[ddc] Creating a hot reload and hot restart test suite.

The hot reload runner currently only supports d8, but I plan to add support for Chrome and VM execution.

Notable changes:
* Creates `package:reload_test` with helpers for running this suite.
* Updates the module loader with D8-specific branches and hooks for hot reload/restart.
* Exposes DDC runtime variables via a `HotReloadTestRuntime` API.
* Ports constant equality hot restart tests from webdev/dwds (validated to fail if either cache-clearing mechanism fails).
* Partially rolls DDC's d8 preamble forward (towards dart2js's).
* Wraps D8's timer implementation with custom timeout logic to better match Chrome's timing semantics when executing with native JS async.

Tests for the framework and matrix updates will be added in an upcoming change.

Change-Id: I2773b29f464cfd0330e4c653c05e117ae150b4a6
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/350021
Reviewed-by: Sigmund Cherem <sigmund@google.com>
Reviewed-by: Nicholas Shahan <nshahan@google.com>
Commit-Queue: Mark Zhou <markzipan@google.com>
This commit is contained in:
MarkZ 2024-03-05 00:12:18 +00:00 committed by Commit Queue
parent c83c1aab9d
commit 210e120fbd
23 changed files with 1260 additions and 32 deletions

View File

@ -4,7 +4,8 @@
// The dev_compiler does not have a publishable public API, instead this is
// intended for other consumers within the Dart SDK.
export 'src/compiler/module_builder.dart' show ModuleFormat, parseModuleFormat;
export 'src/compiler/module_builder.dart'
show ModuleFormat, parseModuleFormat, libraryUriToJsIdentifier;
export 'src/compiler/shared_command.dart' show SharedCompilerOptions;
export 'src/kernel/command.dart' show jsProgramToCode;
export 'src/kernel/compiler.dart' show ProgramCompiler;

View File

@ -23,6 +23,13 @@ if (!self.dart_library) {
throw Error(message);
}
/**
* Returns true if we're running in d8.
*
* TOOD(markzipan): Determine if this d8 check is too inexact.
*/
self.dart_library.isD8 = self.document.head == void 0;
const libraryImports = Symbol('libraryImports');
self.dart_library.libraryImports = libraryImports;
@ -544,7 +551,7 @@ if (!self.dart_library) {
// invalid platforms.
self.dart_library.createScript = (function () {
// Exit early if we aren't modifying an HtmlElement (such as in D8).
if (self.document.createElement == void 0) return;
if (self.dart_library.isD8) return;
// Find the nonce value. (Note, this is only computed once.)
const scripts = Array.from(document.getElementsByTagName('script'));
let nonce;
@ -673,27 +680,7 @@ if (!self.dart_library) {
// {"src": "path/to/script.js", "id": "lookup_id_for_script"}
(function () {
let _currentDirectory = (function () {
let _url;
let lines = new Error().stack.split('\n');
function lookupUrl() {
if (lines.length > 2) {
let match = lines[1].match(/^\s+at (.+):\d+:\d+.*$/);
// Chrome.
if (match) return match[1];
// Chrome nested eval case.
match = lines[1].match(/^\s+at eval [(](.+):\d+:\d+[)]$/);
if (match) return match[1];
// Edge.
match = lines[1].match(/^\s+at.+\((.+):\d+:\d+\)$/);
if (match) return match[1];
// Firefox.
match = lines[0].match(/[<][@](.+):\d+:\d+$/);
if (match) return match[1];
}
// Safari.
return lines[0].match(/[@](.+):\d+:\d+$/)[1];
}
_url = lookupUrl();
let _url = document.currentScript.src;
let lastSlash = _url.lastIndexOf('/');
if (lastSlash == -1) return _url;
let currentDirectory = _url.substring(0, lastSlash + 1);
@ -763,10 +750,13 @@ if (!self.dart_library) {
};
}
// Add a `forceLoadModule` function to the dartLoader since it's required by
// the google3 load strategy.
// Loads a single script onto the page.
// TODO(markzipan): Is there a cleaner way to integrate this?
self.$dartLoader.forceLoadModule = function (moduleName) {
self.$dartLoader.forceLoadScript = function (jsFile) {
if (self.dart_library.isD8) {
self.load(jsFile);
return;
}
let script = self.dart_library.createScript();
let policy = {
createScriptURL: function (src) { return src; }
@ -774,10 +764,15 @@ if (!self.dart_library) {
if (self.trustedTypes && self.trustedTypes.createPolicy) {
policy = self.trustedTypes.createPolicy('dartDdcModuleUrl', policy);
}
script.setAttribute('src', policy.createScriptURL(_currentDirectory + moduleName + '.js'));
script.setAttribute('src', policy.createScriptURL(jsFile));
document.head.appendChild(script);
};
self.$dartLoader.forceLoadModule = function (moduleName) {
let modulePathScript = _currentDirectory + moduleName + '.js';
self.$dartLoader.forceLoadScript(modulePathScript);
};
// Handles JS script downloads and evaluation for a DDC app.
//
// Every DDC application requires exactly one DDCLoader.
@ -808,10 +803,33 @@ if (!self.dart_library) {
this.loadConfig.loadScriptFn(this);
};
// The current hot restart generation.
//
// 0-indexed and increases by 1 on every successful hot restart.
// This value is read to determine the 'current' hot restart generation
// in our hot restart tests. This closely tracks but is not the same as
// `hotRestartIteration` in DDC's runtime.
this.hotRestartGeneration = 0;
// The current 'intended' hot restart generation.
//
// 0-indexed and increases by 1 on every successful hot restart.
// Unlike `hotRestartGeneration`, this is incremented when the intent to
// perform a hot restart is established.
// This is used to synchronize D8 timers and lookup files to load in
// each generation for hot restart testing.
this.intendedHotRestartGeneration = 0;
// The current hot reload generation.
//
// 0-indexed and increases by 1 on every successful hot reload.
this.hotReloadGeneration = 0;
}
// If all scripts from all the current visited scripts queue are being processed
// (loaded or failed).
// True if we are still processing scripts from the script queue.
// 'Processing' means the script is 1) currently being downloaded/parsed
// or 2) the script failed to download and is being retried.
scriptsActivelyBeingLoaded() {
return this.numToLoad > this.numLoaded + this.numFailed;
};
@ -909,7 +927,13 @@ if (!self.dart_library) {
while (this.queue.length > 0 && inflightRequests++ < maxRequests) {
const script = this.queue.shift();
this.numToLoad++;
this.createAndLoadScript(script.src.toString(), script.id, fragment, this.onError.bind(this), this.onLoad.bind(this));
this.createAndLoadScript(
script.src.toString(),
script.id,
fragment,
this.onError.bind(this),
this.onLoad.bind(this)
);
}
if (inflightRequests > 0) {
document.head.appendChild(fragment);
@ -924,10 +948,32 @@ if (!self.dart_library) {
}
};
// Loads modules when running with Chrome.
loadEnqueuedModules() {
this.loadMore(this.loadConfig.maxRequestPoolSize);
};
// Loads modules when running with d8.
loadEnqueuedModulesForD8() {
if (!self.dart_library.isD8) {
throw Error("'loadEnqueuedModulesForD8' is only supported in D8.");
}
// Load all enqueued scripts sequentially.
for (let i = 0; i < this.queue.length; i++) {
const script = this.queue[i];
self.load(script.src.toString());
}
this.queue.length = 0;
// Load the bootstrapper script if it wasn't already loaded.
if (this.loadConfig.tryLoadBootstrapScript) {
const script = this.loadConfig.bootstrapScript;
const src = this.registerScript(script);
self.load(src);
this.loadConfig.tryLoadBootstrapScript = false;
}
return;
};
// Loads just the bootstrap script.
//
// The bootstrapper is loaded only after all other scripts are loaded.
@ -996,6 +1042,18 @@ if (!self.dart_library) {
}
this.processAfterLoadOrErrorEvent();
};
// Initiates a hot reload.
// TODO(markzipan): This function is currently stubbed out for testing.
hotReload() {
this.hotReloadGeneration += 1;
}
// Initiates a hot restart.
hotRestart() {
this.intendedHotRestartGeneration += 1;
self.dart_library.reload();
}
};
let policy = {
@ -1080,9 +1138,10 @@ if (!self.deferred_loader) {
*/
let loadScript = function (moduleUrl, onLoad) {
// A head element won't be created for D8, so just load synchronously.
if (self.document.head == void 0) {
if (self.dart_library.isD8) {
self.load(moduleUrl);
onLoad();
return;
}
let script = dart_library.createScript();
let policy = {

View File

@ -31,6 +31,7 @@ dev_dependencies:
js: any
lints: any
modular_test: any
reload_test: any
shelf: any
sourcemap_testing: any
stack_trace: any

View File

@ -0,0 +1,297 @@
// Copyright (c) 2024, 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:io';
import 'dart:math';
import 'package:_fe_analyzer_shared/src/util/relativize.dart' as fe_shared;
import 'package:dev_compiler/dev_compiler.dart' as ddc_names
show libraryUriToJsIdentifier;
import 'package:front_end/src/compute_platform_binaries_location.dart' as fe;
import 'package:reload_test/ddc_helpers.dart' as ddc_helpers;
import 'package:reload_test/frontend_server_controller.dart';
import 'package:reload_test/hot_reload_memory_filesystem.dart';
final verbose = true;
final debug = true;
/// TODO(markzipan): Add arg parsing for additional execution modes
/// (chrome, VM) and diffs across generations.
Future<void> main(List<String> args) async {
final buildRootUri = fe.computePlatformBinariesLocation(forceBuildDir: true);
// We can use the outline instead of the full SDK dill here.
final ddcPlatformDillUri = buildRootUri.resolve('ddc_outline.dill');
final sdkRoot = Platform.script.resolve('../../../');
final packageConfigUri = sdkRoot.resolve('.dart_tool/package_config.json');
final hotReloadTestUri = sdkRoot.resolve('tests/hot_reload/');
final soundStableDartSdkJsPath =
buildRootUri.resolve('gen/utils/ddc/stable/sdk/ddc/dart_sdk.js').path;
final ddcModuleLoaderJsPath =
sdkRoot.resolve('pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js').path;
final d8PreamblesUri = sdkRoot
.resolve('sdk/lib/_internal/js_dev_runtime/private/preambles/d8.js');
final sealNativeObjectJsUri = sdkRoot.resolve(
'sdk/lib/_internal/js_runtime/lib/preambles/seal_native_object.js');
final d8BinaryUri = sdkRoot.resolveUri(ddc_helpers.d8executableUri);
final allTestsDir = Directory(hotReloadTestUri.path);
// Contains generated code for all tests.
final generatedCodeDir = Directory.systemTemp.createTempSync();
final generatedCodeUri = generatedCodeDir.uri;
_debugPrint('See generated hot reload framework code in $generatedCodeUri');
// The snapshot directory is a staging area the test framework uses to
// construct a compile-able test app across reload/restart generations.
final snapshotDir = Directory.fromUri(generatedCodeUri.resolve('.snapshot/'));
snapshotDir.createSync();
final snapshotUri = snapshotDir.uri;
// Contains files emitted from Frontend Server compiles and recompiles.
final frontendServerEmittedFilesDirUri = generatedCodeUri.resolve('.fes/');
final outputDillUri = frontendServerEmittedFilesDirUri.resolve('output.dill');
final outputIncrementalDillUri =
frontendServerEmittedFilesDirUri.resolve('output_incremental.dill');
// TODO(markzipan): Support custom entrypoints.
final snapshotEntrypointUri = snapshotUri.resolve('main.dart');
final filesystemRootUri = snapshotDir.uri;
final filesystemScheme = 'hot-reload-test';
final snapshotEntrypointLibraryName = fe_shared.relativizeUri(
filesystemRootUri, snapshotEntrypointUri, fe_shared.isWindows);
final snapshotEntrypointWithScheme =
'$filesystemScheme:///$snapshotEntrypointLibraryName';
final ddcArgs = [
'--dartdevc-module-format=ddc',
'--incremental',
'--filesystem-root=${snapshotDir.path}',
'--filesystem-scheme=$filesystemScheme',
'--output-dill=${outputDillUri.path}',
'--output-incremental-dill=${outputIncrementalDillUri.path}',
'--packages=${packageConfigUri.path}',
'--platform=${ddcPlatformDillUri.path}',
'--sdk-root=${sdkRoot.path}',
'--target=dartdevc',
'--verbosity=${verbose ? 'all' : 'info'}',
];
_print('Initializing the Frontend Server.');
var controller = HotReloadFrontendServerController(ddcArgs);
controller.start();
Future<void> shutdown() async {
// Persist the temp directory for debugging.
await controller.stop();
_print('Frontend Server has shut down.');
if (!debug) {
generatedCodeDir.deleteSync(recursive: true);
}
}
for (var testDir in allTestsDir.listSync()) {
if (testDir is! Directory) {
if (testDir is File) {
// Ignore Dart source files, which may be imported as helpers
continue;
}
throw Exception(
'Non-directory or file entity found in ${allTestsDir.path}: $testDir');
}
final testDirParts = testDir.uri.pathSegments;
final testName = testDirParts[testDirParts.length - 2];
final tempUri = generatedCodeUri.resolve('$testName/');
Directory.fromUri(tempUri).createSync();
_print('Generating test assets.', label: testName);
_debugPrint('Emitting JS code to ${tempUri.path}.', label: testName);
var filesystem = HotReloadMemoryFilesystem(tempUri);
var maxGenerations = 0;
// Count the number of generations for this test.
//
// Assumes all files are named like '$name.$integer.dart', where 0 is the
// first generation.
//
// TODO(markzipan): Account for subdirectories.
for (var file in testDir.listSync()) {
if (file is File) {
if (file.path.endsWith('.dart')) {
var strippedName =
file.path.substring(0, file.path.length - '.dart'.length);
var parts = strippedName.split('.');
var generationId = int.parse(parts[parts.length - 1]);
maxGenerations = max(maxGenerations, generationId);
}
}
}
// TODO(markzipan): replace this with a test-configurable main entrypoint.
final mainDartFilePath = testDir.uri.resolve('main.dart').path;
_debugPrint('Test entrypoint: $mainDartFilePath', label: testName);
_print('Generating code over ${maxGenerations + 1} generations.',
label: testName);
// Generate hot reload/restart generations as subdirectories in a loop.
var currentGeneration = 0;
while (currentGeneration <= maxGenerations) {
_debugPrint('Entering generation $currentGeneration', label: testName);
var updatedFilesInCurrentGeneration = <String>[];
// Copy all files in this generation to the snapshot directory with their
// names restored (e.g., path/to/main' from 'path/to/main.0.dart).
// TODO(markzipan): support subdirectories.
_debugPrint(
'Copying Dart files to snapshot directory: ${snapshotDir.path}',
label: testName);
for (var file in testDir.listSync()) {
// Convert a name like `/path/foo.bar.25.dart` to `/path/foo.bar.dart`.
if (file is File && file.path.endsWith('.dart')) {
final baseName = file.uri.pathSegments.last;
final parts = baseName.split('.');
final generationId = int.parse(parts[parts.length - 2]);
if (generationId == currentGeneration) {
// Reconstruct the name of the file without generation indicators.
parts.removeLast(); // Remove `.dart`.
parts.removeLast(); // Remove the generation id.
parts.add('.dart'); // Re-add `.dart`.
final restoredName = parts.join();
final fileSnapshotUri = snapshotUri.resolve(restoredName);
final relativeSnapshotPath = fe_shared.relativizeUri(
filesystemRootUri, fileSnapshotUri, fe_shared.isWindows);
final snapshotPathWithScheme =
'$filesystemScheme:///$relativeSnapshotPath';
updatedFilesInCurrentGeneration.add(snapshotPathWithScheme);
file.copySync(fileSnapshotUri.path);
}
}
}
_print(
'Updated files in generation $currentGeneration: '
'[${updatedFilesInCurrentGeneration.join(', ')}]',
label: testName);
// The first generation calls `compile`, but subsequent ones call
// `recompile`.
// Likewise, use the incremental output directory for `recompile` calls.
String outputDirectoryPath;
_print(
'Compiling generation $currentGeneration with the Frontend Server.',
label: testName);
if (currentGeneration == 0) {
_debugPrint(
'Compiling snapshot entrypoint: $snapshotEntrypointWithScheme',
label: testName);
outputDirectoryPath = outputDillUri.path;
await controller.sendCompileAndAccept(snapshotEntrypointWithScheme);
} else {
_debugPrint(
'Recompiling snapshot entrypoint: $snapshotEntrypointWithScheme',
label: testName);
outputDirectoryPath = outputIncrementalDillUri.path;
// TODO(markzipan): Add logic to reject bad compiles.
await controller.sendRecompileAndAccept(snapshotEntrypointWithScheme,
invalidatedFiles: updatedFilesInCurrentGeneration);
}
_debugPrint(
'Frontend Server successfully compiled outputs to: '
'$outputDirectoryPath',
label: testName);
// Update the memory filesystem with the newly-created JS files
_print(
'Loading generation $currentGeneration files '
'into the memory filesystem.',
label: testName);
final codeFile = File('$outputDirectoryPath.sources');
final manifestFile = File('$outputDirectoryPath.json');
final sourcemapFile = File('$outputDirectoryPath.map');
filesystem.update(
codeFile,
manifestFile,
sourcemapFile,
generation: '$currentGeneration',
);
// Write JS files and sourcemaps to their respective generation.
_print('Writing generation $currentGeneration assets.', label: testName);
_debugPrint('Writing JS assets to ${tempUri.path}', label: testName);
filesystem.writeToDisk(tempUri, generation: '$currentGeneration');
currentGeneration++;
}
_print('Finished emitting assets.', label: testName);
// Run the compiled JS generations with D8.
// TODO(markzipan): Add logic for evaluating with Chrome or the VM.
_print('Preparing to execute JS with D8.', label: testName);
final entrypointModuleName = 'main.dart';
final entrypointLibraryExportName =
ddc_names.libraryUriToJsIdentifier(snapshotEntrypointUri);
final d8BootstrapJsUri = tempUri.resolve('generation0/bootstrap.js');
final d8BootstrapJS = ddc_helpers.generateD8Bootstrapper(
ddcModuleLoaderJsPath: ddcModuleLoaderJsPath,
dartSdkJsPath: soundStableDartSdkJsPath,
entrypointModuleName: entrypointModuleName,
entrypointLibraryExportName: entrypointLibraryExportName,
scriptDescriptors: filesystem.scriptDescriptorForBootstrap,
modifiedFilesPerGeneration: filesystem.generationsToModifiedFilePaths,
);
File.fromUri(d8BootstrapJsUri).writeAsStringSync(d8BootstrapJS);
_debugPrint('Writing D8 bootstrapper: $d8BootstrapJsUri', label: testName);
var process = await startProcess('D8', d8BinaryUri.path, [
sealNativeObjectJsUri.path,
d8PreamblesUri.path,
d8BootstrapJsUri.path
]);
final d8ExitCode = await process.exitCode;
if (d8ExitCode != 0) {
await shutdown();
exit(d8ExitCode);
}
_print('Test passed in D8.', label: testName);
}
await shutdown();
_print('Testing complete.');
}
/// Runs the [command] with [args] in [environment].
///
/// Will echo the commands to the console before running them when running in
/// `verbose` mode.
Future<Process> startProcess(String name, String command, List<String> args,
[Map<String, String> environment = const {}]) {
if (verbose) {
print('Running $name:\n$command ${args.join(' ')}\n');
if (environment.isNotEmpty) {
var environmentVariables =
environment.entries.map((e) => '${e.key}: ${e.value}').join('\n');
print('With environment:\n$environmentVariables\n');
}
}
return Process.start(command, args,
mode: ProcessStartMode.inheritStdio, environment: environment);
}
/// Prints messages if 'verbose' mode is enabled.
void _print(String message, {String? label}) {
if (verbose) {
final labelText = label == null ? '' : '($label)';
print('hot_reload_test$labelText: $message');
}
}
/// Prints messages if 'debug' mode is enabled.
void _debugPrint(String message, {String? label}) {
if (debug) {
final labelText = label == null ? '' : '($label)';
print('DEBUG$labelText: $message');
}
}

1
pkg/reload_test/OWNERS Normal file
View File

@ -0,0 +1 @@
file:/tools/OWNERS_WEB

View File

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View File

@ -0,0 +1,176 @@
// Copyright (c) 2024, 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:convert';
import 'dart:ffi';
import 'dart:io';
final _encoder = JsonEncoder.withIndent(' ');
Uri get d8executableUri {
final arch = Abi.current().toString().split('_')[1];
if (Platform.isWindows) {
return Uri.file('third_party/d8/windows/$arch/d8.exe');
} else if (Platform.isLinux) {
return Uri.file('third_party/d8/linux/$arch/d8');
} else if (Platform.isMacOS) {
return Uri.file('third_party/d8/macos/$arch/d8');
}
throw UnsupportedError('Unsupported platform.');
}
/// Generates the JS bootstrapper for DDC with the DDC module system.
///
/// `scriptDescriptors` maps module IDs to their JS script paths.
/// It has the form:
/// [
/// {
/// "id": "some__module__id.dart"
/// "src": "/path/to/file.js"
/// },
/// ...
/// ]
///
/// `modifiedFilesPerGeneration` maps generation ids to JS files modified in
/// that generation. It has the form:
/// {
/// "0": ["/path/to/file.js", "/path/to/file2.js", ...],
/// "1": ...
/// }
///
/// Note: All JS paths above are relative to `jsFileRoot`.
String generateD8Bootstrapper({
required String ddcModuleLoaderJsPath,
required String dartSdkJsPath,
required String entrypointModuleName,
String jsFileRoot = '/',
String uuid = '00000000-0000-0000-0000-000000000000',
required String entrypointLibraryExportName,
required List<Map<String, String?>> scriptDescriptors,
required Map<String, List<String>> modifiedFilesPerGeneration,
}) {
final d8BootstrapJS = '''
load("$ddcModuleLoaderJsPath");
load("$dartSdkJsPath");
var prerequisiteScripts = [
{
"id": "ddc_module_loader \\0",
"src": "$ddcModuleLoaderJsPath"
},
{
"id": "dart_sdk \\0",
"src": "$dartSdkJsPath"
}
];
let sdk = dart_library.import('dart_sdk');
let scripts = ${_encoder.convert(scriptDescriptors)};
let loadConfig = new self.\$dartLoader.LoadConfiguration();
loadConfig.root = '$jsFileRoot';
// Loading the entrypoint late is only necessary in Chrome.
loadConfig.bootstrapScript = '';
loadConfig.loadScriptFn = function(loader) {
loader.addScriptsToQueue(scripts, null);
loader.loadEnqueuedModulesForD8();
}
loadConfig.ddcEventForLoadStart = /* LOAD_ALL_MODULES_START */ 1;
loadConfig.ddcEventForLoadedOk = /* LOAD_ALL_MODULES_END_OK */ 2;
loadConfig.ddcEventForLoadedError = /* LOAD_ALL_MODULES_END_ERROR */ 3;
let loader = new self.\$dartLoader.DDCLoader(loadConfig);
// Record prerequisite scripts' fully resolved URLs.
prerequisiteScripts.forEach(script => loader.registerScript(script));
// Note: these variables should only be used in non-multi-app scenarios since
// they can be arbitrarily overridden based on multi-app load order.
self.\$dartLoader.loadConfig = loadConfig;
self.\$dartLoader.loader = loader;
// Append hot reload runner-specific logic.
let modifiedFilesPerGeneration = ${_encoder.convert(modifiedFilesPerGeneration)};
let previousGenerations = new Set();
self.\$dartReloadModifiedModules = function(subAppName, callback) {
let expectedName = "$entrypointModuleName";
if (subAppName !== expectedName) {
throw Error("Unexpected app name " + subAppName
+ " (expected: " + expectedName + "). "
+ "Hot Reload Runner does not support multiple subapps, so only "
+ "one app name should be provided across reloads/restarts.");
}
// Resolve the next generation's directory and load all modified files.
let nextGeneration = self.\$dartLoader.loader.intendedHotRestartGeneration;
if (previousGenerations.has(nextGeneration)) {
throw Error('Fatal error: Previous generations are being re-run.');
}
previousGenerations.add(nextGeneration);
// Increment the hot restart generation before loading files or running main
// This lets us treat the value in `hotRestartGeneration` as the 'current'
// generation until local state is updated.
self.\$dartLoader.loader.hotRestartGeneration += 1;
let modifiedFilePaths = modifiedFilesPerGeneration[nextGeneration];
// Stop if the next generation does not exist.
if (modifiedFilePaths == void 0) {
return;
}
// Load all modified files.
for (let i = 0; i < modifiedFilePaths.length; i++) {
self.\$dartLoader.forceLoadScript(modifiedFilePaths[i]);
}
// Run main.
callback();
}
// D8 does not support the core Timer API methods beside `setTimeout` so our
// D8 preambles provide a custom implementation.
//
// Timers in this implementatiom are simulated, so they all complete before
// native JS `await` boundaries. If this boundary occurs before our runtime's
// `hotRestartIteration` counter increments, we can observe Futures not being
// cancelled in D8 when they might otherwise have been in Chrome.
//
// To resolve this, we record and increment hot restart generations early
// and wrap timer functions with custom cancellation logic.
self.setTimeout = function(setTimeout) {
let currentHotRestartIteration =
self.\$dartLoader.loader.intendedHotRestartGeneration;
return function(f, ms) {
var internalCallback = function() {
if (currentHotRestartIteration ==
self.\$dartLoader.loader.intendedHotRestartGeneration) {
f();
}
}
setTimeout(internalCallback, ms);
};
}(self.setTimeout);
// DDC also has a runtime implementation of microtasks' `scheduleImmediate`
// that more closely matches Chrome's behavior. We enable this implementation
// by deleting the our custom implementation in D8's preamble.
self.scheduleImmediate = void 0;
// Begin loading libraries
loader.nextAttempt();
// Invoke main through the d8 preamble to ensure the code is running
// within the fake event loop.
self.dartMainRunner(function () {
dart_library.start("$entrypointModuleName",
"$uuid",
"$entrypointModuleName",
"$entrypointLibraryExportName",
false
);
});
''';
return d8BootstrapJS;
}

View File

@ -0,0 +1,175 @@
// Copyright (c) 2024, 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:frontend_server/starter.dart';
const frontEndResponsePrefix = 'result ';
const fakeBoundaryKey = '42';
final debug = false;
/// Controls and synchronizes the Frontend Server during hot reloaad tests.
///
/// The Frontend Server accepts the following instructions:
/// > compile <input.dart>
///
/// > recompile [<input.dart>] <boundary-key>
/// <dart file>
/// <dart file>
/// ...
/// <boundary-key>
///
/// > accept
///
/// > quit
// 'compile' and 'recompile' instructions output the following on completion:
// result <boundary-key>
// <compiler output>
// <boundary-key> [<output.dill>]
class HotReloadFrontendServerController {
final List<String> frontendServerArgs;
/// Used to send commands to the Frontend Server.
final StreamController<List<int>> input;
/// Contains output messages from the Frontend Server.
final StreamController<List<int>> output;
/// Contains one event per completed Frontend Server 'compile' or 'recompile'
/// command.
final StreamController<String> compileCommandOutputChannel;
/// An iterator over `compileCommandOutputChannel`.
/// Should be awaited after every 'compile' or 'recompile' command.
final StreamIterator<String> synchronizer;
bool started = false;
String? _boundaryKey;
late Future<int> frontendServerExitCode;
HotReloadFrontendServerController._(this.frontendServerArgs, this.input,
this.output, this.compileCommandOutputChannel, this.synchronizer);
factory HotReloadFrontendServerController(List<String> frontendServerArgs) {
var input = StreamController<List<int>>();
var output = StreamController<List<int>>();
var compileCommandOutputChannel = StreamController<String>();
var synchronizer = StreamIterator(compileCommandOutputChannel.stream);
return HotReloadFrontendServerController._(frontendServerArgs, input,
output, compileCommandOutputChannel, synchronizer);
}
/// Runs the Frontend Server in-memory in incremental mode.
/// Must be called once before interacting with the Frontend Server.
void start() {
if (started) {
print('Frontend Server has already been started.');
return;
}
output.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((String s) {
if (_boundaryKey == null) {
if (s.startsWith(frontEndResponsePrefix)) {
_boundaryKey = s.substring(frontEndResponsePrefix.length);
}
} else {
if (s.startsWith(_boundaryKey!)) {
compileCommandOutputChannel.add(_boundaryKey!);
_boundaryKey = null;
}
}
});
frontendServerExitCode = starter(
frontendServerArgs,
input: input.stream,
output: IOSink(output.sink),
);
started = true;
}
Future<void> sendCompile(String dartSourcePath) async {
if (!started) {
throw Exception('Frontend Server has not been started yet.');
}
final command = 'compile $dartSourcePath\n';
if (debug) {
print('Sending instruction to Frontend Server:\n$command');
}
input.add(command.codeUnits);
await synchronizer.moveNext();
}
Future<void> sendCompileAndAccept(String dartSourcePath) async {
await sendCompile(dartSourcePath);
sendAccept();
}
Future<void> sendRecompile(String entrypointPath,
{List<String> invalidatedFiles = const [],
String boundaryKey = fakeBoundaryKey}) async {
if (!started) {
throw Exception('Frontend Server has not been started yet.');
}
final command = 'recompile $entrypointPath $boundaryKey\n'
'${invalidatedFiles.join('\n')}\n$boundaryKey\n';
if (debug) {
print('Sending instruction to Frontend Server:\n$command');
}
input.add(command.codeUnits);
await synchronizer.moveNext();
}
Future<void> sendRecompileAndAccept(String entrypointPath,
{List<String> invalidatedFiles = const [],
String boundaryKey = fakeBoundaryKey}) async {
await sendRecompile(entrypointPath,
invalidatedFiles: invalidatedFiles, boundaryKey: boundaryKey);
sendAccept();
}
void sendAccept() {
if (!started) {
throw Exception('Frontend Server has not been started yet.');
}
final command = 'accept\n';
// TODO(markzipan): We should reject certain invalid compiles (e.g., those
// with unimplemented or invalid nodes).
if (debug) {
print('Sending instruction to Frontend Server:\n$command');
}
input.add(command.codeUnits);
}
void _sendQuit() {
if (!started) {
throw Exception('Frontend Server has not been started yet.');
}
final command = 'quit\n';
if (debug) {
print('Sending instruction to Frontend Server:\n$command');
}
input.add(command.codeUnits);
}
/// Cleanly shuts down the Frontend Server.
Future<void> stop() async {
_sendQuit();
var exitCode = await frontendServerExitCode;
started = false;
if (exitCode != 0) {
print('Frontend Server exited with non-zero code: $exitCode');
exit(exitCode);
}
}
}

View File

@ -0,0 +1,180 @@
// Copyright (c) 2024, 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:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dev_compiler/dev_compiler.dart' as ddc_names
show libraryUriToJsIdentifier;
/// A pseudo in-memory filesystem with helpers to aid the hot reload runner.
///
/// The Frontend Server outputs web sources and sourcemaps as concatenated
/// single files per-invocation. A manifest file contains the byte offsets
/// for resolving the individual files.
/// Adapted from:
/// https://github.com/flutter/flutter/blob/ac7879e2aa6de40afec1fe2af9730a8d55de3e06/packages/flutter_tools/lib/src/web/memory_fs.dart
class HotReloadMemoryFilesystem {
/// The root directory's URI from which JS file are being served.
final Uri jsRootUri;
final Map<String, Uint8List> files = {};
final Map<String, Uint8List> sourcemaps = {};
/// Maps generation numbers to a list of changed libraries.
final Map<String, List<LibraryInfo>> generationChanges = {};
final List<LibraryInfo> libraries = [];
final List<LibraryInfo> firstGenerationLibraries = [];
HotReloadMemoryFilesystem(this.jsRootUri);
/// Writes the entirety of this filesystem to [outputDirectoryUri].
///
/// [clearWritableState] clears generation-specific state so that old
/// generations' files aren't rewritten.
void writeToDisk(Uri outputDirectoryUri,
{required String generation, bool clearWritableState = true}) {
assert(Directory.fromUri(outputDirectoryUri).existsSync(),
'$outputDirectoryUri does not exist.');
files.forEach((path, content) {
final outputFileUri =
outputDirectoryUri.resolve('generation$generation/').resolve(path);
final outputFile = File.fromUri(outputFileUri);
outputFile.createSync(recursive: true);
outputFile.writeAsBytesSync(content);
});
if (clearWritableState) {
files.clear();
sourcemaps.clear();
}
}
/// Returns a map of generation number to modified files' paths.
///
/// Used to determine which JS files should be loaded per generation.
Map<String, List<String>> get generationsToModifiedFilePaths => {
for (var e in generationChanges.entries)
e.key: e.value.map((info) => info.jsSourcePath).toList()
};
/// Returns all scripts in the filesystem in a form that can be ingested by
/// the DDC module system's bootstrapper.
/// Files must only be in the first generation.
List<Map<String, String?>> get scriptDescriptorForBootstrap {
// TODO(markzipan): This currently isn't ordered, which may cause problems
// with cycles.
final scriptsJson = <Map<String, String?>>[];
for (var library in firstGenerationLibraries) {
final scriptDescriptor = <String, String?>{
'id': library.dartSourcePath,
'src': library.jsSourcePath,
};
scriptsJson.add(scriptDescriptor);
}
return scriptsJson;
}
/// Update the filesystem with the provided source and manifest files.
///
/// Returns the list of updated files. Also associates file info with a
/// generation label.
List<String> update(
File codeFile,
File manifestFile,
File sourcemapFile, {
required String generation,
}) {
final updatedFiles = <String>[];
final codeBytes = codeFile.readAsBytesSync();
final sourcemapBytes = sourcemapFile.readAsBytesSync();
final manifest = Map.castFrom<dynamic, dynamic, String, Object?>(
json.decode(manifestFile.readAsStringSync()) as Map);
generationChanges[generation] = [];
for (final filePath in manifest.keys) {
final fileUri = Uri.file(filePath);
final Map<String, dynamic> offsets =
Map.castFrom<dynamic, dynamic, String, Object?>(
manifest[filePath] as Map);
final codeOffsets = (offsets['code'] as List<dynamic>).cast<int>();
final sourcemapOffsets =
(offsets['sourcemap'] as List<dynamic>).cast<int>();
if (codeOffsets.length != 2 || sourcemapOffsets.length != 2) {
continue;
}
final codeStart = codeOffsets[0];
final codeEnd = codeOffsets[1];
if (codeStart < 0 || codeEnd > codeBytes.lengthInBytes) {
continue;
}
final byteView = Uint8List.view(
codeBytes.buffer,
codeStart,
codeEnd - codeStart,
);
final fileName =
filePath.startsWith('/') ? filePath.substring(1) : filePath;
files[fileName] = byteView;
final libraryName = ddc_names.libraryUriToJsIdentifier(fileUri);
// TODO(markzipan): This is an overly simple heuristic to resolve the
// original Dart file. Replace this if it no longer holds.
var dartFileName = fileName;
if (dartFileName.endsWith('.lib.js')) {
dartFileName =
fileName.substring(0, fileName.length - '.lib.js'.length);
}
final fullyResolvedFileUri =
jsRootUri.resolve('generation$generation/$fileName');
// TODO(markzipan): Update this if module and library names are no
// longer the same.
final libraryInfo = LibraryInfo(
moduleName: libraryName,
libraryName: libraryName,
dartSourcePath: dartFileName,
jsSourcePath: fullyResolvedFileUri.path);
libraries.add(libraryInfo);
if (generation == '0') {
firstGenerationLibraries.add(libraryInfo);
}
generationChanges[generation]!.add(libraryInfo);
updatedFiles.add(fileName);
final sourcemapStart = sourcemapOffsets[0];
final sourcemapEnd = sourcemapOffsets[1];
if (sourcemapStart < 0 || sourcemapEnd > sourcemapBytes.lengthInBytes) {
continue;
}
final sourcemapView = Uint8List.view(
sourcemapBytes.buffer,
sourcemapStart,
sourcemapEnd - sourcemapStart,
);
final sourcemapName = '$fileName.map';
sourcemaps[sourcemapName] = sourcemapView;
}
return updatedFiles;
}
}
/// Bundles information associated with a DDC library.
class LibraryInfo {
final String moduleName;
final String libraryName;
final String dartSourcePath;
final String jsSourcePath;
LibraryInfo(
{required this.moduleName,
required this.libraryName,
required this.dartSourcePath,
required this.jsSourcePath});
@override
String toString() =>
'LibraryInfo($moduleName, $libraryName, $dartSourcePath, $jsSourcePath)';
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2024, 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.
// Contains runtime utils for reload tests.
//
// Should be imported directly by test files, not test runners.
export 'src/_reload_utils_api.dart'
if (dart.library.io) 'src/_vm_reload_utils.dart'
if (dart.library.js_interop) 'src/_ddc_reload_utils.dart';

View File

@ -0,0 +1,34 @@
// Copyright (c) 2024, 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.
// Helper functions for the hot reload test suite.
//
// The structure here reflects interfaces defined in DDC's
// `ddc_module_loader.js`.
import 'dart:js_interop';
extension type _DartLoader(JSObject _) implements JSObject {
external _DDCLoader get loader;
}
extension type _DDCLoader(JSObject _) implements JSObject {
external void hotReload();
external void hotRestart();
external int get hotReloadGeneration;
external int get hotRestartGeneration;
}
@JS('\$dartLoader')
external _DartLoader get _dartLoader;
final _ddcLoader = _dartLoader.loader;
int get hotRestartGeneration => _ddcLoader.hotRestartGeneration;
void hotRestart() => _ddcLoader.hotRestart();
int get hotReloadGeneration => _ddcLoader.hotReloadGeneration;
void hotReload() => _ddcLoader.hotReload();

View File

@ -0,0 +1,15 @@
// Copyright (c) 2024, 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.
// Helper functions for the hot reload test suite.
int get hotRestartGeneration =>
throw Exception('Not implemented on this platform.');
void hotRestart() => throw Exception('Not implemented on this platform.');
int get hotReloadGeneration =>
throw Exception('Not implemented on this platform.');
void hotReload() => throw Exception('Not implemented on this platform.');

View File

@ -0,0 +1,15 @@
// Copyright (c) 2024, 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.
// Helper functions for the hot reload test suite.
int get hotRestartGeneration =>
throw Exception('Not implemented on this platform.');
void hotRestart() => throw Exception('Not implemented on this platform.');
int get hotReloadGeneration =>
throw Exception('Not implemented on this platform.');
void hotReload() => throw Exception('Not implemented on this platform.');

View File

@ -0,0 +1,17 @@
name: reload_test
# This package is not intended for consumption on pub.dev. DO NOT publish.
publish_to: none
description: >
Small framework for testing hot reload and hot restart across Dart backends.
environment:
sdk: '>=3.3.0 <4.0.0'
# Use 'any' constraints here; we get our versions from the DEPS file.
dependencies:
dev_compiler: any
frontend_server: any
# Use 'any' constraints here; we get our versions from the DEPS file.
dev_dependencies:
lints: any

View File

@ -277,6 +277,7 @@ if (typeof global != "undefined") self = global; // Node.js.
// Global properties. "self" refers to the global object, so adding a
// property to "self" defines a global variable.
self.self = self;
self.dartMainRunner = function(main, args) {
// Initialize.
var action = function() { main(args); }
@ -287,5 +288,51 @@ if (typeof global != "undefined") self = global; // Node.js.
self.setInterval = addInterval;
self.clearInterval = cancelTimer;
self.scheduleImmediate = addTask;
self.self = self;
// Some js-interop code accesses 'window' as 'self.window'
if (typeof self.window == "undefined") self.window = self;
function computeCurrentScript() {
try {
throw new Error();
} catch (e) {
var stack = e.stack;
// The V8 stack looks like:
// at computeCurrentScript (preambles/d8.js:286:13)
// at Object.currentScript (preambles/d8.js:308:31)
// at init.currentScript (/tmp/foo.js:308:19)
// at /tmp/foo.js:320:7
// at /tmp/foo.js:331:4
// Sometimes the 'init.currentScript' line is in the format without the
// function name, so match with or without parentheses.
// vvvvvvvvvvvv Optional prefix up to '('.
var re = /^ *at (?:[^(]*\()?(.*):[0-9]*:[0-9]*\)?$/mg
// Optional ')' at end ^^^
var lastMatch = null;
do {
var match = re.exec(stack);
if (match != null) lastMatch = match;
} while (match != null);
return lastMatch[1];
}
}
// Adding a 'document' is dangerous since it invalidates the 'typeof document'
// test to see if we are running in the browser. It means that the runtime
// needs to do more precise checks.
// Note that we can't run "currentScript" right away, since that would give
// us the location of the preamble file. Instead we wait for the first access
// which should happen just before invoking main. At this point we are in
// the main file and setting the currentScript property is correct.
var cachedCurrentScript = null;
self.document = {
get currentScript() {
if (cachedCurrentScript == null) {
cachedCurrentScript = { src: computeCurrentScript() };
}
return cachedCurrentScript;
}
};
})(self);

View File

@ -0,0 +1,33 @@
// Copyright (c) 2024, 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 'package:expect/expect.dart';
import 'package:reload_test/reload_test_utils.dart';
var x = 'Hello World';
void main() {
Expect.equals('Hello World', x);
Expect.equals(0, hotRestartGeneration);
scheduleMicrotask(() {
Expect.equals(0, hotRestartGeneration);
});
Future<Null>.microtask(() {
throw x;
}).catchError((e, stackTrace) {
Expect.equals("Hello World", e);
Expect.equals(0, hotRestartGeneration);
}).then((_) {
Expect.equals(0, hotRestartGeneration);
});
Future.delayed(Duration(seconds: 5), () {
throw Exception('Future from main.0.dart before hot restart. '
'This should never run.');
});
hotRestart();
}

View File

@ -0,0 +1,33 @@
// Copyright (c) 2024, 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 'package:expect/expect.dart';
import 'package:reload_test/reload_test_utils.dart';
var x = 'Hello Foo';
void main() {
Expect.equals('Hello Foo', x);
Expect.equals(1, hotRestartGeneration);
scheduleMicrotask(() {
Expect.equals(1, hotRestartGeneration);
});
Future<Null>.microtask(() {
throw x;
}).catchError((e, stackTrace) {
Expect.equals("Hello Foo", e);
Expect.equals(1, hotRestartGeneration);
}).then((_) {
Expect.equals(1, hotRestartGeneration);
});
Future.delayed(Duration(seconds: 5), () {
throw Exception('Future from main.1.dart before hot restart. '
'This should never run.');
});
hotRestart();
}

View File

@ -0,0 +1,33 @@
// Copyright (c) 2024, 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 'package:expect/expect.dart';
import 'package:reload_test/reload_test_utils.dart';
var x = 'Hello Bar';
void main() {
Expect.equals('Hello Bar', x);
Expect.equals(2, hotRestartGeneration);
scheduleMicrotask(() {
Expect.equals(2, hotRestartGeneration);
});
Future<Null>.microtask(() {
throw x;
}).catchError((e, stackTrace) {
Expect.equals("Hello Bar", e);
Expect.equals(2, hotRestartGeneration);
}).then((_) {
Expect.equals(2, hotRestartGeneration);
});
Future.delayed(Duration(seconds: 5), () {
throw Exception('Future from main.2.dart before hot restart. '
'This should never run.');
});
hotRestart();
}

View File

@ -0,0 +1,10 @@
// Copyright (c) 2024, 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.
class B {
final int x;
const B(this.x);
}
B get value1 => const B(2);

View File

@ -0,0 +1,8 @@
// Copyright (c) 2024, 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 'library_a.dart';
int variableToModifyToForceRecompile = 23;
B get value2 => const B(2);

View File

@ -0,0 +1,8 @@
// Copyright (c) 2024, 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 'library_a.dart';
int variableToModifyToForceRecompile = 45;
B get value2 => const B(2);

View File

@ -0,0 +1,37 @@
// Copyright (c) 2024, 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:core';
import 'dart:html';
import 'package:expect/expect.dart';
import 'package:reload_test/reload_test_utils.dart';
import 'library_a.dart';
import 'library_b.dart';
/// Tests for constant semantics across hot restart in DDC.
///
/// DDC has multiple layers of constant caching. Failing to clear them can
/// result in stale constants being referenced across hot restarts.
///
/// Cases tested include:
/// 1) Failing to clear all constant caches.
/// An old 'ConstObject' is returned, which fails to reflect the edited
/// 'variableToModifyToForceRecompile'.
/// 2) Clearing constant caches but failing to clear constant containers.
/// Constants in reloaded modules fail to compare with constants in stale
/// constant containers, causing 'ConstantEqualityFailure's.
class ConstObject {
const ConstObject();
String get text => 'ConstObject('
'reloadVariable: $variableToModifyToForceRecompile, '
'${value1 == value2 ? 'ConstantEqualitySuccess' : 'ConstantEqualityFailure'})';
}
void main() {
Expect.equals('ConstObject(reloadVariable: 23, ConstantEqualitySuccess)',
'${const ConstObject().text}');
hotRestart();
}

View File

@ -0,0 +1,36 @@
// Copyright (c) 2024, 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:core';
import 'dart:html';
import 'package:expect/expect.dart';
import 'package:reload_test/reload_test_utils.dart';
import 'library_a.dart';
import 'library_b.dart';
/// Tests for constant semantics across hot restart in DDC.
///
/// DDC has multiple layers of constant caching. Failing to clear them can
/// result in stale constants being referenced across hot restarts.
///
/// Cases tested include:
/// 1) Failing to clear all constant caches.
/// An old 'ConstObject' is returned, which fails to reflect the edited
/// 'variableToModifyToForceRecompile'.
/// 2) Clearing constant caches but failing to clear constant containers.
/// Constants in reloaded modules fail to compare with constants in stale
/// constant containers, causing 'ConstantEqualityFailure's.
class ConstObject {
const ConstObject();
String get text => 'ConstObject('
'reloadVariable: $variableToModifyToForceRecompile, '
'${value1 == value2 ? 'ConstantEqualitySuccess' : 'ConstantEqualityFailure'})';
}
void main() {
Expect.equals('ConstObject(reloadVariable: 45, ConstantEqualitySuccess)',
'${const ConstObject().text}');
}