debugger: Fix restart only working once per session (#51247)

Nelson Campos , Claude Opus 4.6 (1M context) , and Anthony Eid created

`Session::restart_task` is set to `Some` when a restart is initiated but
never cleared back to `None`. The guard at the top of `restart()` checks
`self.restart_task.is_some()` and returns early, so only the first
restart attempt succeeds.

This primarily affects debug adapters that advertise
`supportsRestartRequest` dynamically via a `CapabilitiesEvent` after
launch, such as the Flutter debug adapter.

Related: https://github.com/zed-extensions/dart/issues/45

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [ ] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
(N/A — no UI changes)

Release Notes:

- debugger: Fixed debug session restart only working once when the
adapter supports DAP restart requests.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

crates/debugger_ui/src/tests.rs                |  8 +
crates/debugger_ui/src/tests/debugger_panel.rs | 74 +++++++++++++++++++
crates/project/src/debugger/session.rs         | 28 ++++--
3 files changed, 97 insertions(+), 13 deletions(-)

Detailed changes

crates/debugger_ui/src/tests.rs 🔗

@@ -132,7 +132,13 @@ pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
             .workspace()
             .read(cx)
             .panel::<DebugPanel>(cx)
-            .and_then(|panel| panel.read(cx).active_session())
+            .and_then(|panel| {
+                panel
+                    .read(cx)
+                    .sessions_with_children
+                    .keys()
+                    .max_by_key(|session| session.read(cx).session_id(cx))
+            })
             .map(|session| session.read(cx).running_state().read(cx).session())
             .cloned()
             .context("Failed to get active session")

crates/debugger_ui/src/tests/debugger_panel.rs 🔗

@@ -27,7 +27,7 @@ use std::{
     path::Path,
     sync::{
         Arc,
-        atomic::{AtomicBool, Ordering},
+        atomic::{AtomicBool, AtomicUsize, Ordering},
     },
 };
 use terminal_view::terminal_panel::TerminalPanel;
@@ -2481,3 +2481,75 @@ async fn test_adapter_shutdown_with_child_sessions_on_app_quit(
         "Child session should have received disconnect request"
     );
 }
+
+#[gpui::test]
+async fn test_restart_request_is_not_sent_more_than_once_until_response(
+    executor: BackgroundExecutor,
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+
+    let fs = FakeFs::new(executor.clone());
+
+    fs.insert_tree(
+        path!("/project"),
+        json!({
+            "main.rs": "First line\nSecond line\nThird line\nFourth line",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+    let workspace = init_test_workspace(&project, cx).await;
+    let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+    let session = start_debug_session(&workspace, cx, move |client| {
+        client.on_request::<dap::requests::Initialize, _>(move |_, _| {
+            Ok(dap::Capabilities {
+                supports_restart_request: Some(true),
+                ..Default::default()
+            })
+        });
+    })
+    .unwrap();
+
+    let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+    let restart_count = Arc::new(AtomicUsize::new(0));
+
+    client.on_request::<dap::requests::Restart, _>({
+        let restart_count = restart_count.clone();
+        move |_, _| {
+            restart_count.fetch_add(1, Ordering::SeqCst);
+            Ok(())
+        }
+    });
+
+    // This works because the restart request sender is on the foreground thread
+    // so it will start running after the gpui update stack is cleared
+    session.update(cx, |session, cx| {
+        session.restart(None, cx);
+        session.restart(None, cx);
+        session.restart(None, cx);
+    });
+
+    cx.run_until_parked();
+
+    assert_eq!(
+        restart_count.load(Ordering::SeqCst),
+        1,
+        "Only one restart request should be sent while a restart is in-flight"
+    );
+
+    session.update(cx, |session, cx| {
+        session.restart(None, cx);
+    });
+
+    cx.run_until_parked();
+
+    assert_eq!(
+        restart_count.load(Ordering::SeqCst),
+        2,
+        "A second restart should be allowed after the first one completes"
+    );
+}

crates/project/src/debugger/session.rs 🔗

@@ -2187,21 +2187,27 @@ impl Session {
             self.capabilities.supports_restart_request.unwrap_or(false) && !self.is_terminated();
 
         self.restart_task = Some(cx.spawn(async move |this, cx| {
-            let _ = this.update(cx, |session, cx| {
+            this.update(cx, |session, cx| {
                 if supports_dap_restart {
-                    session
-                        .request(
-                            RestartCommand {
-                                raw: args.unwrap_or(Value::Null),
-                            },
-                            Self::fallback_to_manual_restart,
-                            cx,
-                        )
-                        .detach();
+                    session.request(
+                        RestartCommand {
+                            raw: args.unwrap_or(Value::Null),
+                        },
+                        Self::fallback_to_manual_restart,
+                        cx,
+                    )
                 } else {
                     cx.emit(SessionStateEvent::Restart);
+                    Task::ready(None)
                 }
-            });
+            })
+            .unwrap_or_else(|_| Task::ready(None))
+            .await;
+
+            this.update(cx, |session, _cx| {
+                session.restart_task = None;
+            })
+            .ok();
         }));
     }