Re-establish breakpoints on hot-restart  (#7205)

diff --git a/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart b/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart
index 360a8b1..b524563 100644
--- a/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart
+++ b/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart
@@ -3,8 +3,10 @@
 // found in the LICENSE file.
 
 import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_app/src/screens/debugger/breakpoints.dart';
 import 'package:devtools_app/src/screens/debugger/call_stack.dart';
 import 'package:devtools_app/src/screens/debugger/codeview.dart';
+import 'package:devtools_app/src/service/service_extension_widgets.dart';
 import 'package:devtools_test/helpers.dart';
 import 'package:devtools_test/integration_test.dart';
 import 'package:flutter/material.dart';
@@ -59,27 +61,44 @@
       isTrue,
     );
 
-    logStatus('opening the "more" menu');
+    logStatus('Navigating to line 57...');
 
-    final moreMenuFinder = find.byType(PopupMenuButton<ScriptPopupMenuOption>);
-    expect(moreMenuFinder, findsOneWidget);
-    await tester.tap(moreMenuFinder);
-    await tester.pumpAndSettle(safePumpDuration);
+    await goToLine(tester, lineNumber: 57);
 
-    logStatus('selecting the go-to-line menu option');
+    logStatus('looking for line 57');
 
-    final goToLineOptionFinder = find.textContaining('Go to line number');
-    expect(goToLineOptionFinder, findsOneWidget);
-    await tester.tap(goToLineOptionFinder);
-    await tester.pumpAndSettle(safePumpDuration);
+    // Look for the line 57 gutter item:
+    final gutter57Finder = findGutterItemWithText('57');
+    expect(gutter57Finder, findsOneWidget);
 
-    logStatus('entering line number in the go-to-line dialog');
+    // Look for the line 57 line item:
+    final line57Finder = findLineItemWithText("print('Hello!');");
+    expect(line57Finder, findsOneWidget);
 
-    final goToLineInputFinder = find.widgetWithText(TextField, 'Line Number');
-    expect(goToLineInputFinder, findsOneWidget);
-    await tester.enterText(goToLineInputFinder, '30');
-    await tester.testTextInput.receiveAction(TextInputAction.done);
-    await tester.pumpAndSettle(safePumpDuration);
+    // Verify that the gutter item and line item are aligned:
+    expect(
+      areHorizontallyAligned(
+        gutter57Finder,
+        line57Finder,
+        tester: tester,
+      ),
+      isTrue,
+    );
+
+    logStatus('setting a breakpoint');
+
+    // Tap on the gutter for the line to set a breakpoint:
+    await tester.tap(gutter57Finder);
+    await tester.pumpAndSettle(longPumpDuration);
+
+    logStatus('performing a hot restart');
+
+    await tester.tap(find.byType(HotRestartButton));
+    await tester.pumpAndSettle(longPumpDuration);
+
+    logStatus('Navigating to line 30...');
+
+    await goToLine(tester, lineNumber: 30);
 
     logStatus('looking for line 30');
 
@@ -107,6 +126,11 @@
     await tester.tap(gutter30Finder);
     await tester.pumpAndSettle(longPumpDuration);
 
+    logStatus('verifying breakpoints');
+
+    final bpSetBeforeRestart = findBreakpointWithText('main.dart:57');
+    expect(bpSetBeforeRestart, findsOneWidget);
+
     logStatus('pausing at breakpoint');
 
     final topFrameFinder = findStackFrameWithText('incrementCounter');
@@ -170,6 +194,30 @@
   return widgetACenter.dy == widgetBCenter.dy;
 }
 
+Future<void> goToLine(WidgetTester tester, {required int lineNumber}) async {
+  logStatus('opening the "more" menu');
+
+  final moreMenuFinder = find.byType(PopupMenuButton<ScriptPopupMenuOption>);
+  expect(moreMenuFinder, findsOneWidget);
+  await tester.tap(moreMenuFinder);
+  await tester.pumpAndSettle(safePumpDuration);
+
+  logStatus('selecting the go-to-line menu option');
+
+  final goToLineOptionFinder = find.textContaining('Go to line number');
+  expect(goToLineOptionFinder, findsOneWidget);
+  await tester.tap(goToLineOptionFinder);
+  await tester.pumpAndSettle(safePumpDuration);
+
+  logStatus('entering line number $lineNumber in the go-to-line dialog');
+
+  final goToLineInputFinder = find.widgetWithText(TextField, 'Line Number');
+  expect(goToLineInputFinder, findsOneWidget);
+  await tester.enterText(goToLineInputFinder, '$lineNumber');
+  await tester.testTextInput.receiveAction(TextInputAction.done);
+  await tester.pumpAndSettle(safePumpDuration);
+}
+
 T getWidgetFromFinder<T>(Finder finder) =>
     finder.first.evaluate().first.widget as T;
 
@@ -192,3 +240,8 @@
       of: find.byType(CallStack),
       matching: find.richTextContaining(text),
     );
+
+Finder findBreakpointWithText(String text) => find.descendant(
+      of: find.byType(Breakpoints),
+      matching: find.richTextContaining(text),
+    );
diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart
index f8792e1..fa52410 100644
--- a/packages/devtools_app/lib/src/framework/framework_core.dart
+++ b/packages/devtools_app/lib/src/framework/framework_core.dart
@@ -103,7 +103,7 @@
           service,
           onClosed: finishedCompleter.future,
         );
-        breakpointManager.initialize();
+        await breakpointManager.initialize();
         return true;
       } catch (e, st) {
         if (logException) {
diff --git a/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart b/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart
index 77bbd42..01835e1 100644
--- a/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart
+++ b/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart
@@ -35,19 +35,21 @@
 
   String get _isolateRefId => _isolateRef?.id ?? '';
 
-  void initialize() {
+  final _previousIsolateBreakpoints = <BreakpointAndSourcePosition>[];
+
+  Future<void> initialize() async {
     final isolate =
         serviceConnection.serviceManager.isolateManager.selectedIsolate.value;
     if (initialSwitchToIsolate && isolate != null) {
-      switchToIsolate(
+      await switchToIsolate(
         serviceConnection.serviceManager.isolateManager.selectedIsolate.value,
       );
     }
 
     addAutoDisposeListener(
       serviceConnection.serviceManager.isolateManager.selectedIsolate,
-      () {
-        switchToIsolate(
+      () async {
+        await switchToIsolate(
           serviceConnection.serviceManager.isolateManager.selectedIsolate.value,
         );
       },
@@ -60,40 +62,38 @@
     );
   }
 
-  void switchToIsolate(IsolateRef? isolateRef) async {
+  Future<void> switchToIsolate(IsolateRef? isolateRef) async {
     _isolateRef = isolateRef;
 
     if (isolateRef == null) {
-      _breakpoints.value.clear();
-      _breakpointsWithLocation.value.clear();
+      _saveAndClearCurrentBreakpoints();
       return;
     }
 
-    final isolate = await _service.getIsolate(_isolateRefId);
-    if (isolate.id != _isolateRefId) {
-      // Current request is obsolete.
-      return;
+    final breakpointsForIsolate =
+        await _getBreakpointsForIsolate(_isolateRefId);
+    if (breakpointsForIsolate.isNotEmpty) {
+      // If the isolate already has breakpoints, then update them:
+      await _updateBreakpoints(
+        breakpoints: breakpointsForIsolate,
+        isolateId: _isolateRefId,
+      );
+    } else {
+      // Otherwise, re-establish the breakpoints from the previous isolate:
+      await _setUpBreakpoints(
+        breakpoints: _previousIsolateBreakpoints,
+        isolateRef: isolateRef,
+      );
     }
-
-    _breakpoints.value = isolate.breakpoints ?? [];
-
-    // Build _breakpointsWithLocation from _breakpoints.
-    // ignore: unawaited_futures
-    Future.wait(
-      _breakpoints.value.map(breakpointManager.createBreakpointWithLocation),
-    ).then((list) {
-      if (isolate.id != _isolateRefId) {
-        // Current request is obsolete.
-        return;
-      }
-      _breakpointsWithLocation.value = list.toList()..sort();
-    });
   }
 
-  void clearCache() {
+  void clearCache({required bool isServiceShutdown}) {
     _breakPositionsMap.clear();
     _breakpoints.value = [];
     _breakpointsWithLocation.value = [];
+    if (isServiceShutdown) {
+      _previousIsolateBreakpoints.clear();
+    }
   }
 
   Future<void> clearBreakpoints() async {
@@ -138,6 +138,16 @@
     }
   }
 
+  void _saveAndClearCurrentBreakpoints() {
+    if (breakpointsWithLocation.value.isNotEmpty) {
+      _previousIsolateBreakpoints
+        ..clear()
+        ..addAll(_breakpointsWithLocation.value);
+    }
+    _breakpoints.value = [];
+    _breakpointsWithLocation.value = [];
+  }
+
   void _updateAfterIsolateReload(
     Event _,
   ) async {
@@ -175,6 +185,87 @@
     ]);
   }
 
+  Future<List<Breakpoint>> _getBreakpointsForIsolate(String isolateId) async {
+    final isolate = await _service.getIsolate(isolateId);
+    if (isolate.id != _isolateRefId) {
+      // Current request is obsolete.
+      return [];
+    }
+
+    // Ignore attempts from DWDS to re-establish breakpoints because DevTools is
+    // now in charge of re-establishing breakpoints:
+    final connectedToDwds =
+        serviceConnection.serviceManager.connectedApp?.isDartWebAppNow ?? false;
+    if (connectedToDwds) return [];
+
+    return isolate.breakpoints ?? [];
+  }
+
+  Future<void> _updateBreakpoints({
+    required List<Breakpoint> breakpoints,
+    required String isolateId,
+  }) async {
+    _breakpoints.value = breakpoints;
+    // Build _breakpointsWithLocation from _breakpoints.
+    await Future.wait(
+      _breakpoints.value.map(breakpointManager.createBreakpointWithLocation),
+    ).then((list) {
+      if (isolateId != _isolateRefId) {
+        // Current request is obsolete.
+        return;
+      }
+      _breakpointsWithLocation.value = list.toList()..sort();
+    });
+  }
+
+  Future<void> _setUpBreakpoints({
+    required List<BreakpointAndSourcePosition> breakpoints,
+    required IsolateRef isolateRef,
+  }) async {
+    final scriptUriToRef = await _scriptRefsForBreakpoints(
+      breakpoints: breakpoints,
+      isolateRef: isolateRef,
+    );
+
+    for (final breakpoint in breakpoints) {
+      final newScriptRef = scriptUriToRef[breakpoint.scriptUri];
+      final breakpointLine = breakpoint.line;
+
+      final scriptId = newScriptRef?.id;
+      if (scriptId != null && breakpointLine != null) {
+        await addBreakpoint(scriptId, breakpointLine);
+      }
+    }
+  }
+
+  Future<Map<String, ScriptRef>> _scriptRefsForBreakpoints({
+    required List<BreakpointAndSourcePosition> breakpoints,
+    required IsolateRef isolateRef,
+  }) async {
+    final bpScriptUris = breakpoints.fold(
+      <String>{},
+      (scriptSet, breakpoint) {
+        final scriptUri = breakpoint.scriptUri;
+        if (scriptUri != null) {
+          scriptSet.add(scriptUri);
+        }
+        return scriptSet;
+      },
+    );
+
+    final newScripts = await scriptManager.retrieveAndSortScripts(isolateRef);
+    final scriptUriToRef =
+        newScripts.fold(<String, ScriptRef>{}, (scriptMap, script) {
+      final scriptUri = script.uri;
+      if (scriptUri != null && bpScriptUris.contains(scriptUri)) {
+        scriptMap[scriptUri] = script;
+      }
+      return scriptMap;
+    });
+
+    return scriptUriToRef;
+  }
+
   /// Return the list of valid positions for breakpoints for a given script.
   Future<List<SourcePosition>> getBreakablePositions(
     IsolateRef? isolateRef,
@@ -239,7 +330,7 @@
     }
   }
 
-  void _handleDebugEvent(Event event) {
+  Future<void> _handleDebugEvent(Event event) async {
     if (event.isolate!.id != _isolateRefId) return;
 
     switch (event.kind) {
@@ -247,18 +338,20 @@
       // that knows how to notify when performing a list edit operation.
       case EventKind.kBreakpointAdded:
         final breakpoint = event.breakpoint!;
+        final isDuplicate =
+            _breakpoints.value.any((bp) => bp.id == breakpoint.id);
+        if (isDuplicate) break;
         _breakpoints.value = [..._breakpoints.value, breakpoint];
 
-        unawaited(
-          breakpointManager.createBreakpointWithLocation(breakpoint).then((bp) {
-            final list = [
-              ..._breakpointsWithLocation.value,
-              bp,
-            ]..sort();
-
-            _breakpointsWithLocation.value = list;
-          }),
-        );
+        await breakpointManager
+            .createBreakpointWithLocation(breakpoint)
+            .then((bp) {
+          final list = [
+            ..._breakpointsWithLocation.value,
+            bp,
+          ]..sort();
+          _breakpointsWithLocation.value = list;
+        });
 
         break;
       case EventKind.kBreakpointResolved:
@@ -269,21 +362,30 @@
           breakpoint,
         ];
 
-        unawaited(
-          breakpointManager.createBreakpointWithLocation(breakpoint).then((bp) {
-            final list = _breakpointsWithLocation.value.toList();
-            // Remote the bp with the older, unresolved information from the list.
-            list.removeWhere((breakpoint) => bp.breakpoint.id == bp.id);
-            // Add the bp with the newer, resolved information.
-            list.add(bp);
-            list.sort();
-            _breakpointsWithLocation.value = list;
-          }),
-        );
+        await breakpointManager
+            .createBreakpointWithLocation(breakpoint)
+            .then((bp) {
+          final list = _breakpointsWithLocation.value;
+          // Remove the bp with the older, unresolved information from the list.
+          list.removeWhere((breakpoint) => breakpoint.id == bp.id);
+          // Add the bp with the newer, resolved information.
+          list.add(bp);
+          list.sort();
+          _breakpointsWithLocation.value = list;
+        });
 
         break;
 
       case EventKind.kBreakpointRemoved:
+        // Ignore any breakpoints removed during a hot restart, because the VM
+        // service removes them before resuming the isolate and then performing
+        // the restart. Note we only track hot restarts triggered by DevTools,
+        // if a hot-restart was triggered by another client we won't know.
+        // See https://github.com/flutter/flutter/issues/134470
+        final hotRestartInProgress = serviceConnection
+            .serviceManager.isolateManager.hotRestartInProgress;
+        if (hotRestartInProgress) break;
+
         final breakpoint = event.breakpoint;
 
         _breakpoints.value = [
diff --git a/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart b/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart
index 4801769..9214117 100644
--- a/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart
+++ b/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart
@@ -82,7 +82,7 @@
 
   /// Method to call after the vm service shuts down.
   void _onServiceShutdown() {
-    _clearCaches();
+    _clearCaches(isServiceShutdown: true);
 
     _hasTruncatedFrames.value = false;
     unawaited(_getStackOperation?.cancel());
@@ -484,9 +484,9 @@
     await _populateFrameInfo(stackInfo.frames, truncated: stackInfo.truncated);
   }
 
-  void _clearCaches() {
+  void _clearCaches({bool isServiceShutdown = false}) {
     _lastEvent = null;
-    breakpointManager.clearCache();
+    breakpointManager.clearCache(isServiceShutdown: isServiceShutdown);
   }
 
   Future<void> _populateScripts(Isolate isolate) async {
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index cc61007..cca4832 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -46,6 +46,7 @@
 * Improved contrast of profiling details when displaying profiler hits in dark
 mode. - [#7178](https://github.com/flutter/devtools/pull/7178)
 * Fixed syntax highlighting for comments when the source file uses `\r\n` line endings [#7190](https://github.com/flutter/devtools/pull/7190)
+* Re-establish breakpoints after a hot-restart. - [#7205](https://github.com/flutter/devtools/pull/7205)
 
 ## Network profiler updates
 
diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart
index 07d9ec9..1f7bfdb 100644
--- a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart
+++ b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart
@@ -43,7 +43,17 @@
         body: const Center(
           child: Text('Hello, World!'),
         ),
+        floatingActionButton: FloatingActionButton(
+          onPressed: _printHello,
+          tooltip: 'Say hi',
+          child: const Icon(Icons.abc),
+        ),
       ),
     );
   }
+
+  void _printHello() {
+    // ignore: avoid_print, for testing.
+    print('Hello!');
+  }
 }
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_animated_physical_model_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_animated_physical_model_selected.png
index 96e0b31..70a6872 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_animated_physical_model_selected.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_animated_physical_model_selected.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png
index 1d80bdd..ee3d0e1 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_richtext_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_richtext_selected.png
index c9c3b3a..a9f6174 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_richtext_selected.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_richtext_selected.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_scaffold_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_scaffold_selected.png
index b3c4024..2b66f6a 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_scaffold_selected.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_scaffold_selected.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png
index 995c7b6..0c2309d 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center_details_tree.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center_details_tree.png
index 3cab036..2162267 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center_details_tree.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center_details_tree.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png
index e73f0d8..8f259c2 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png
index e73f0d8..8f259c2 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png
Binary files differ
diff --git a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart
index 2548443..5aa305e 100644
--- a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart
+++ b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart
@@ -50,6 +50,10 @@
 
   final _isolateRunnableCompleters = <String?, Completer<void>>{};
 
+  // TODO(https://github.com/flutter/flutter/issues/134470): Track hot-restarts
+  // triggered by other clients.
+  bool hotRestartInProgress = false;
+
   Future<void> init(List<IsolateRef> isolates) async {
     await initIsolates(isolates);
   }
@@ -132,6 +136,9 @@
         () => Completer<void>(),
       );
       isolateRunnable.complete();
+      if (hotRestartInProgress) {
+        hotRestartInProgress = false;
+      }
     } else if (event.kind == EventKind.kIsolateStart &&
         !event.isolate!.isSystemIsolate!) {
       await _registerIsolate(event.isolate!);
diff --git a/packages/devtools_app_shared/lib/src/service/service_manager.dart b/packages/devtools_app_shared/lib/src/service/service_manager.dart
index e4d7d85..19a9232 100644
--- a/packages/devtools_app_shared/lib/src/service/service_manager.dart
+++ b/packages/devtools_app_shared/lib/src/service/service_manager.dart
@@ -433,7 +433,12 @@
 
   /// This can throw an [RPCError].
   Future<void> performHotRestart() async {
-    await callServiceOnMainIsolate(hotRestartServiceName);
+    isolateManager.hotRestartInProgress = true;
+    try {
+      await callServiceOnMainIsolate(hotRestartServiceName);
+    } catch (_) {
+      isolateManager.hotRestartInProgress = false;
+    }
   }
 }