diff --git a/pkg/dev_compiler/lib/dev_compiler.dart b/pkg/dev_compiler/lib/dev_compiler.dart index ca0a8bdbf27..67114e98894 100644 --- a/pkg/dev_compiler/lib/dev_compiler.dart +++ b/pkg/dev_compiler/lib/dev_compiler.dart @@ -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; diff --git a/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js b/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js index f4c02ff9d5f..58354a2f6c0 100644 --- a/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js +++ b/pkg/dev_compiler/lib/js/ddc/ddc_module_loader.js @@ -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 = { diff --git a/pkg/dev_compiler/pubspec.yaml b/pkg/dev_compiler/pubspec.yaml index daa6dacc601..b7b0f32b2dd 100644 --- a/pkg/dev_compiler/pubspec.yaml +++ b/pkg/dev_compiler/pubspec.yaml @@ -31,6 +31,7 @@ dev_dependencies: js: any lints: any modular_test: any + reload_test: any shelf: any sourcemap_testing: any stack_trace: any diff --git a/pkg/dev_compiler/test/hot_reload_suite.dart b/pkg/dev_compiler/test/hot_reload_suite.dart new file mode 100644 index 00000000000..74e984d400a --- /dev/null +++ b/pkg/dev_compiler/test/hot_reload_suite.dart @@ -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 main(List 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 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 = []; + + // 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 startProcess(String name, String command, List args, + [Map 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'); + } +} diff --git a/pkg/reload_test/OWNERS b/pkg/reload_test/OWNERS new file mode 100644 index 00000000000..f5bd90c5161 --- /dev/null +++ b/pkg/reload_test/OWNERS @@ -0,0 +1 @@ +file:/tools/OWNERS_WEB diff --git a/pkg/reload_test/analysis_options.yaml b/pkg/reload_test/analysis_options.yaml new file mode 100644 index 00000000000..572dd239d09 --- /dev/null +++ b/pkg/reload_test/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/pkg/reload_test/lib/ddc_helpers.dart b/pkg/reload_test/lib/ddc_helpers.dart new file mode 100644 index 00000000000..6750d5a0774 --- /dev/null +++ b/pkg/reload_test/lib/ddc_helpers.dart @@ -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> scriptDescriptors, + required Map> 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; +} diff --git a/pkg/reload_test/lib/frontend_server_controller.dart b/pkg/reload_test/lib/frontend_server_controller.dart new file mode 100644 index 00000000000..60a731644e0 --- /dev/null +++ b/pkg/reload_test/lib/frontend_server_controller.dart @@ -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 +/// +/// > recompile [] +/// +/// +/// ... +/// +/// +/// > accept +/// +/// > quit + +// 'compile' and 'recompile' instructions output the following on completion: +// result +// +// [] +class HotReloadFrontendServerController { + final List frontendServerArgs; + + /// Used to send commands to the Frontend Server. + final StreamController> input; + + /// Contains output messages from the Frontend Server. + final StreamController> output; + + /// Contains one event per completed Frontend Server 'compile' or 'recompile' + /// command. + final StreamController compileCommandOutputChannel; + + /// An iterator over `compileCommandOutputChannel`. + /// Should be awaited after every 'compile' or 'recompile' command. + final StreamIterator synchronizer; + + bool started = false; + String? _boundaryKey; + late Future frontendServerExitCode; + + HotReloadFrontendServerController._(this.frontendServerArgs, this.input, + this.output, this.compileCommandOutputChannel, this.synchronizer); + + factory HotReloadFrontendServerController(List frontendServerArgs) { + var input = StreamController>(); + var output = StreamController>(); + var compileCommandOutputChannel = StreamController(); + 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 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 sendCompileAndAccept(String dartSourcePath) async { + await sendCompile(dartSourcePath); + sendAccept(); + } + + Future sendRecompile(String entrypointPath, + {List 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 sendRecompileAndAccept(String entrypointPath, + {List 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 stop() async { + _sendQuit(); + var exitCode = await frontendServerExitCode; + started = false; + if (exitCode != 0) { + print('Frontend Server exited with non-zero code: $exitCode'); + exit(exitCode); + } + } +} diff --git a/pkg/reload_test/lib/hot_reload_memory_filesystem.dart b/pkg/reload_test/lib/hot_reload_memory_filesystem.dart new file mode 100644 index 00000000000..501c9ccd01f --- /dev/null +++ b/pkg/reload_test/lib/hot_reload_memory_filesystem.dart @@ -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 files = {}; + final Map sourcemaps = {}; + + /// Maps generation numbers to a list of changed libraries. + final Map> generationChanges = {}; + final List libraries = []; + final List 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> 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> get scriptDescriptorForBootstrap { + // TODO(markzipan): This currently isn't ordered, which may cause problems + // with cycles. + final scriptsJson = >[]; + for (var library in firstGenerationLibraries) { + final scriptDescriptor = { + '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 update( + File codeFile, + File manifestFile, + File sourcemapFile, { + required String generation, + }) { + final updatedFiles = []; + final codeBytes = codeFile.readAsBytesSync(); + final sourcemapBytes = sourcemapFile.readAsBytesSync(); + final manifest = Map.castFrom( + json.decode(manifestFile.readAsStringSync()) as Map); + + generationChanges[generation] = []; + for (final filePath in manifest.keys) { + final fileUri = Uri.file(filePath); + final Map offsets = + Map.castFrom( + manifest[filePath] as Map); + final codeOffsets = (offsets['code'] as List).cast(); + final sourcemapOffsets = + (offsets['sourcemap'] as List).cast(); + + 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)'; +} diff --git a/pkg/reload_test/lib/reload_test_utils.dart b/pkg/reload_test/lib/reload_test_utils.dart new file mode 100644 index 00000000000..3ee1068023b --- /dev/null +++ b/pkg/reload_test/lib/reload_test_utils.dart @@ -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'; diff --git a/pkg/reload_test/lib/src/_ddc_reload_utils.dart b/pkg/reload_test/lib/src/_ddc_reload_utils.dart new file mode 100644 index 00000000000..eed7dfa9b41 --- /dev/null +++ b/pkg/reload_test/lib/src/_ddc_reload_utils.dart @@ -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(); diff --git a/pkg/reload_test/lib/src/_reload_utils_api.dart b/pkg/reload_test/lib/src/_reload_utils_api.dart new file mode 100644 index 00000000000..772cc014355 --- /dev/null +++ b/pkg/reload_test/lib/src/_reload_utils_api.dart @@ -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.'); diff --git a/pkg/reload_test/lib/src/_vm_reload_utils.dart b/pkg/reload_test/lib/src/_vm_reload_utils.dart new file mode 100644 index 00000000000..772cc014355 --- /dev/null +++ b/pkg/reload_test/lib/src/_vm_reload_utils.dart @@ -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.'); diff --git a/pkg/reload_test/pubspec.yaml b/pkg/reload_test/pubspec.yaml new file mode 100644 index 00000000000..30b08a73945 --- /dev/null +++ b/pkg/reload_test/pubspec.yaml @@ -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 diff --git a/sdk/lib/_internal/js_dev_runtime/private/preambles/d8.js b/sdk/lib/_internal/js_dev_runtime/private/preambles/d8.js index b47d4709bd2..21dba16a98d 100644 --- a/sdk/lib/_internal/js_dev_runtime/private/preambles/d8.js +++ b/sdk/lib/_internal/js_dev_runtime/private/preambles/d8.js @@ -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); diff --git a/tests/hot_reload/framework_timing_test/main.0.dart b/tests/hot_reload/framework_timing_test/main.0.dart new file mode 100644 index 00000000000..3d3bdab3e75 --- /dev/null +++ b/tests/hot_reload/framework_timing_test/main.0.dart @@ -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.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(); +} diff --git a/tests/hot_reload/framework_timing_test/main.1.dart b/tests/hot_reload/framework_timing_test/main.1.dart new file mode 100644 index 00000000000..52bbbe46c78 --- /dev/null +++ b/tests/hot_reload/framework_timing_test/main.1.dart @@ -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.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(); +} diff --git a/tests/hot_reload/framework_timing_test/main.2.dart b/tests/hot_reload/framework_timing_test/main.2.dart new file mode 100644 index 00000000000..37af36ec11d --- /dev/null +++ b/tests/hot_reload/framework_timing_test/main.2.dart @@ -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.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(); +} diff --git a/tests/hot_reload/hot_restart_constant_equality/library_a.0.dart b/tests/hot_reload/hot_restart_constant_equality/library_a.0.dart new file mode 100644 index 00000000000..e2018bfcc6d --- /dev/null +++ b/tests/hot_reload/hot_restart_constant_equality/library_a.0.dart @@ -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); diff --git a/tests/hot_reload/hot_restart_constant_equality/library_b.0.dart b/tests/hot_reload/hot_restart_constant_equality/library_b.0.dart new file mode 100644 index 00000000000..8b389ce0f7f --- /dev/null +++ b/tests/hot_reload/hot_restart_constant_equality/library_b.0.dart @@ -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); diff --git a/tests/hot_reload/hot_restart_constant_equality/library_b.1.dart b/tests/hot_reload/hot_restart_constant_equality/library_b.1.dart new file mode 100644 index 00000000000..b9a5a9c2e3a --- /dev/null +++ b/tests/hot_reload/hot_restart_constant_equality/library_b.1.dart @@ -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); diff --git a/tests/hot_reload/hot_restart_constant_equality/main.0.dart b/tests/hot_reload/hot_restart_constant_equality/main.0.dart new file mode 100644 index 00000000000..1db3e409003 --- /dev/null +++ b/tests/hot_reload/hot_restart_constant_equality/main.0.dart @@ -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(); +} diff --git a/tests/hot_reload/hot_restart_constant_equality/main.1.dart b/tests/hot_reload/hot_restart_constant_equality/main.1.dart new file mode 100644 index 00000000000..ac9d4ec0af2 --- /dev/null +++ b/tests/hot_reload/hot_restart_constant_equality/main.1.dart @@ -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}'); +}