blob: 2b96fe9ccf4f5e17569a4d630c59c79d1520fde1 [file] [log] [blame]
// 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.
import 'dart:convert';
import 'dart:developer' show Service;
import 'dart:io' as io;
import 'package:vm_service/vm_service.dart' show VmService, ReloadReport;
import 'package:vm_service/vm_service_io.dart' as vm_service_io;
import '../hot_reload_receipt.dart';
int get hotRestartGeneration =>
throw Exception('Not implemented on this platform.');
Future<void> hotRestart() async =>
throw Exception('Not implemented on this platform.');
int _reloadCounter = 0;
int get hotReloadGeneration => _reloadCounter;
HotReloadHelper? _hotReloadHelper;
Future<void> hotReload({bool expectRejection = false}) async {
_hotReloadHelper ??= await HotReloadHelper.create();
HotReloadReceipt reloadReceipt;
if (expectRejection) {
reloadReceipt = await _hotReloadHelper!._rejectNextGeneration();
} else {
_reloadCounter++;
reloadReceipt = await _hotReloadHelper!._reloadNextGeneration();
}
// Write reload receipt with a leading tag to be recognized by the reload
// suite runner and validated.
print('${HotReloadReceipt.hotReloadReceiptTag}'
'${jsonEncode(reloadReceipt.toJson())}');
}
/// Helper to mediate with the vm service protocol.
///
/// Contains logic to initiate a connection with the vm service protocol on the
/// Dart VM running the current program and for requesting a hot-reload request
/// on the current isolate.
///
/// Adapted from:
/// https://github.com/dart-lang/sdk/blob/dbcf24cedbe4d3a8eccaa51712f0c98b92173ad2/pkg/dev_compiler/tool/hotreload/hot_reload_helper.dart#L77
class HotReloadHelper {
/// ID for the isolate running the test.
final String _id;
final VmService _vmService;
/// The output directory under which generation directories are saved.
final Uri testOutputDirUri;
/// File name of the dill (full or fragment) to be reloaded.
///
/// We assume that:
/// * Every generation only loads one dill
/// * All dill files have the same name across every generation
final String dillName;
/// File name of a dill that contains compile time errors.
///
/// We assume that this generation contained expected compile time errors and
/// should be rejected at runtime.
final String errorDillName;
/// The current generation being executed by the VM.
int generation = 0;
HotReloadHelper._(this._vmService, this._id, this.testOutputDirUri,
this.dillName, this.errorDillName);
/// Create a helper that is bound to the current VM and isolate.
static Future<HotReloadHelper> create() async {
final info =
await Service.controlWebServer(enable: true, silenceOutput: true);
final observatoryUri = info.serverUri;
if (observatoryUri == null) {
print('Error: no VM service found. '
'Please invoke dart with `--enable-vm-service`.');
io.exit(1);
}
final wsUri = 'ws://${observatoryUri.authority}${observatoryUri.path}ws';
final vmService = await vm_service_io.vmServiceConnectUri(wsUri);
final vm = await vmService.getVM();
final id =
vm.isolates!.firstWhere((isolate) => !isolate.isSystemIsolate!).id!;
final currentIsolateGroup = vm.isolateGroups!
.firstWhere((isolateGroup) => !isolateGroup.isSystemIsolateGroup!);
final dillUri = Uri.file(currentIsolateGroup.name!);
final generationPart =
dillUri.pathSegments[dillUri.pathSegments.length - 2];
if (!generationPart.startsWith('generation')) {
print('Error: Unable to find generation in dill file: $dillUri.');
io.exit(1);
}
final dillName = dillUri.pathSegments.last;
final errorDillName = dillName.replaceAll('.dill', '.error.dill');
return HotReloadHelper._(
vmService, id, dillUri.resolve('../'), dillName, errorDillName);
}
/// Trigger a hot-reload on the current isolate for the next generation.
///
/// Also checks that the generation afterwards exists. If not, the VM service
/// is disconnected to allow the VM to complete.
Future<HotReloadReceipt> _reloadNextGeneration() async {
generation += 1;
final nextGenerationDillUri =
testOutputDirUri.resolve('generation$generation/$dillName');
print('Reloading: $nextGenerationDillUri');
var reloadReport = await _vmService.reloadSources(_id,
rootLibUri: nextGenerationDillUri.path);
if (!reloadReport.success!) {
throw Exception('Reload for generation $generation was rejected.\n'
'${reloadReport.reasonForCancelling}');
}
var reloadReceipt = HotReloadReceipt(
generation: generation,
status: Status.accepted,
);
if (!hasNextGeneration) await cleanUp();
return reloadReceipt;
}
Future<HotReloadReceipt> _rejectNextGeneration() async {
generation += 1;
HotReloadReceipt reloadReceipt;
final errorDillFile = io.File.fromUri(
testOutputDirUri.resolve('generation$generation/$errorDillName'));
if (errorDillFile.existsSync()) {
// This generation contained a compile time error that has already been
// validated and should be rejected.
reloadReceipt = HotReloadReceipt(
generation: generation,
status: Status.rejected,
rejectionMessage: HotReloadReceipt.compileTimeErrorMessage);
} else {
final nextGenerationDillUri =
testOutputDirUri.resolve('generation$generation/$dillName');
print('Reloading (expecting rejection): $nextGenerationDillUri');
final reloadReport = await _vmService.reloadSources(_id,
rootLibUri: nextGenerationDillUri.path);
if (reloadReport.success!) {
throw Exception('Generation $generation was not rejected. Verify the '
'calls of `hotReload(expectRejection: true)` in the test source '
'match the rejected generation files.');
}
reloadReceipt = HotReloadReceipt(
generation: generation,
status: Status.rejected,
rejectionMessage: reloadReport.reasonForCancelling,
);
}
if (!hasNextGeneration) await cleanUp();
return reloadReceipt;
}
bool get hasNextGeneration {
final nextNextGenerationDirUri =
testOutputDirUri.resolve('generation${generation + 1}');
return io.Directory.fromUri(nextNextGenerationDirUri).existsSync();
}
Future<void> cleanUp() async => await _vmService.dispose();
}
/// Extension to expose the reason for a failed reload.
///
/// This is currently in the json response from the vm-service, but not exposed
/// as an API in [ReloadReport].
extension on ReloadReport {
String? get reasonForCancelling {
final notices = this.json?['notices'] as List?;
if (notices != null) {
for (final notice in notices) {
if (notice['type'] == 'ReasonForCancelling' ||
notice['type'] == 'ReasonForCancellingReload') {
return notice['message'] as String?;
}
}
}
return null;
}
}