Fix duplicate connection/logs in Webdev (#2635)

* bump dwds version to 24.3.9

* updated dwds constraints to 24.3.11

* fix duplicate logs

* fix duplicate logs

* Revert "fix duplicate logs"

This reverts commit 2ccd2d9d854ed43e473284088383c7a66dda68e2.

* updated changelog

* check and resuse active app state for each  appId

* addressed comments
diff --git a/webdev/CHANGELOG.md b/webdev/CHANGELOG.md
index 448a206..49ef1f1 100644
--- a/webdev/CHANGELOG.md
+++ b/webdev/CHANGELOG.md
@@ -1,7 +1,9 @@
-## 3.7.2-wip
+## 3.7.2
 
+- Fixed duplicate app logs on page refresh by preventing multiple stdout listeners for the same appId.
 - Adds `--offline` flag [#2483](https://github.com/dart-lang/webdev/pull/2483).
 - Support the `--hostname` flag when the `--tls-cert-key` and `--tls-cert-chain` flags are present [#2588](https://github.com/dart-lang/webdev/pull/2588).
+- Update `dwds` constraint to `24.3.11`.
 
 ## 3.7.1
 
diff --git a/webdev/lib/src/daemon/app_domain.dart b/webdev/lib/src/daemon/app_domain.dart
index 66853c1..8d750e5 100644
--- a/webdev/lib/src/daemon/app_domain.dart
+++ b/webdev/lib/src/daemon/app_domain.dart
@@ -63,46 +63,33 @@
 
   Future<void> _handleAppConnections(WebDevServer server) async {
     final dwds = server.dwds!;
+
     // The connection is established right before `main()` is called.
     await for (final appConnection in dwds.connectedApps) {
+      final appId = appConnection.request.appId;
+
+      // Check if we already have an active app state for this appId
+      if (_appStates.containsKey(appId)) {
+        // Reuse existing connection, just run main again
+        appConnection.runMain();
+        continue;
+      }
+
       final debugConnection = await dwds.debugConnection(appConnection);
       final debugUri = debugConnection.ddsUri ?? debugConnection.uri;
       final vmService = await vmServiceConnectUri(debugUri);
-      final appId = appConnection.request.appId;
-      unawaited(debugConnection.onDone.then((_) {
-        sendEvent('app.log', {
-          'appId': appId,
-          'log': 'Lost connection to device.',
-        });
-        sendEvent('app.stop', {
-          'appId': appId,
-        });
-        daemon.shutdown();
-      }));
+
       sendEvent('app.start', {
         'appId': appId,
         'directory': Directory.current.path,
         'deviceId': 'chrome',
         'launchMode': 'run'
       });
-      // TODO(grouma) - limit the catch to the appropriate error.
-      try {
-        await vmService.streamCancel('Stdout');
-      } catch (_) {}
-      try {
-        await vmService.streamListen('Stdout');
-      } catch (_) {}
-      try {
-        vmService.onServiceEvent.listen(_onServiceEvent);
-        await vmService.streamListen('Service');
-      } catch (_) {}
+
+      // Set up VM service listeners for this appId
       // ignore: cancel_subscriptions
-      final stdOutSub = vmService.onStdoutEvent.listen((log) {
-        sendEvent('app.log', {
-          'appId': appId,
-          'log': utf8.decode(base64.decode(log.bytes!)),
-        });
-      });
+      final stdOutSub = await _setupVmServiceListeners(appId, vmService);
+
       sendEvent('app.debugPort', {
         'appId': appId,
         'port': debugConnection.port,
@@ -120,9 +107,19 @@
 
       appConnection.runMain();
 
+      // Handle connection termination - send events first, then cleanup
       unawaited(debugConnection.onDone.whenComplete(() {
-        appState.dispose();
-        _appStates.remove(appId);
+        sendEvent('app.log', {
+          'appId': appId,
+          'log': 'Lost connection to device.',
+        });
+        sendEvent('app.stop', {
+          'appId': appId,
+        });
+        daemon.shutdown();
+
+        // Clean up app resources
+        _cleanupAppConnection(appId, appState);
       }));
     }
 
@@ -223,6 +220,36 @@
     return true;
   }
 
+  /// Sets up VM service listeners for the given appId.
+  /// Returns the stdout subscription.
+  Future<StreamSubscription<Event>> _setupVmServiceListeners(
+      String appId, VmService vmService) async {
+    try {
+      vmService.onServiceEvent.listen(_onServiceEvent);
+      await vmService.streamListen(EventStreams.kService);
+    } catch (_) {}
+
+    // ignore: cancel_subscriptions
+    final stdoutSubscription = vmService.onStdoutEvent.listen((log) {
+      sendEvent('app.log', {
+        'appId': appId,
+        'log': utf8.decode(base64.decode(log.bytes!)),
+      });
+    });
+
+    try {
+      await vmService.streamListen(EventStreams.kStdout);
+    } catch (_) {}
+
+    return stdoutSubscription;
+  }
+
+  /// Cleans up an app connection and its associated listeners.
+  void _cleanupAppConnection(String appId, _AppState appState) {
+    appState.dispose();
+    _appStates.remove(appId);
+  }
+
   @override
   void dispose() {
     _isShutdown = true;
diff --git a/webdev/lib/src/version.dart b/webdev/lib/src/version.dart
index 7d40e1d..2cda305 100644
--- a/webdev/lib/src/version.dart
+++ b/webdev/lib/src/version.dart
@@ -1,2 +1,2 @@
 // Generated code. Do not modify.
-const packageVersion = '3.7.2-wip';
+const packageVersion = '3.7.2';
diff --git a/webdev/pubspec.yaml b/webdev/pubspec.yaml
index 5e7ea31..d1706c7 100644
--- a/webdev/pubspec.yaml
+++ b/webdev/pubspec.yaml
@@ -1,6 +1,6 @@
 name: webdev
 # Every time this changes you need to run `dart run build_runner build`.
-version: 3.7.2-wip
+version: 3.7.2
 # We should not depend on a dev SDK before publishing.
 # publish_to: none
 description: >-
@@ -19,7 +19,7 @@
   crypto: ^3.0.2
   dds: ^4.1.0
   # Pin DWDS to avoid dependency conflicts with vm_service:
-  dwds: 24.3.5
+  dwds: 24.3.11
   http: ^1.0.0
   http_multi_server: ^3.2.0
   io: ^1.0.3