debugger: Separate Continue Program and Continue Thread actions (#63135)

Fixes #63091

Continue Program was sending `singleThread: true` whenever the adapter
supported it, which caused some adapters to resume only the selected
thread. It now leaves `singleThread` unset and resumes all threads.

Adapters that support single-thread execution now show separate Continue
Program and Continue Thread buttons. Other adapters still show only
Continue Program.

<img width="247" height="108" alt="image"
src="https://github.com/user-attachments/assets/5059d291-4809-488b-9169-e4817852fb07"
/>

This change also:

- Updates thread states from the continue response instead of waiting
for a [continued event, which adapters are not expected to
send](https://github.com/microsoft/debug-adapter-protocol/blob/bf8a5d27e8040044b84b863f90916e08925ee811/debugAdapterProtocol.json#L243-L246).
- Treats an omitted allThreadsContinued in a continued event as true, as
required by the [DAP
specification](https://github.com/microsoft/debug-adapter-protocol/blob/main/debugAdapterProtocol.json#L260-L263).

Release Notes:

- Added a Continue Thread action and fixed Continue Program to resume
all threads.
This commit is contained in:
Smit Barmase authored and GitHub committed 2026-08-24 20:32:08 +00:00
1 parent 3bd76bb912
commit 26b8374358
7 files changed
+286 -37

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" d="M5 3l6 5-6 5V3Z"/></svg>

After

Width:  |  Height:  |  Size: 189 B

+61 -25
View File
@@ -4,10 +4,11 @@ use crate::session::running::RunningState;
use crate::session::running::breakpoint_list::BreakpointList;
use crate::{
ClearAllBreakpoints, Continue, CopyDebugAdapterArguments, Detach, FocusBreakpointList,
FocusConsole, FocusFrames, FocusLoadedSources, FocusModules, FocusTerminal, FocusVariables,
NewProcessModal, NewProcessMode, Pause, RerunSession, StepInto, StepOut, StepOver, Stop,
ToggleExpandItem, ToggleSessionPicker, ToggleThreadPicker, persistence, spawn_task_or_modal,
ClearAllBreakpoints, Continue, ContinueThread, CopyDebugAdapterArguments, Detach,
FocusBreakpointList, FocusConsole, FocusFrames, FocusLoadedSources, FocusModules,
FocusTerminal, FocusVariables, NewProcessModal, NewProcessMode, Pause, RerunSession, StepInto,
StepOut, StepOver, Stop, ToggleExpandItem, ToggleSessionPicker, ToggleThreadPicker,
persistence, spawn_task_or_modal,
};
use anyhow::{Context as _, Result, anyhow};
use collections::IndexMap;
@@ -726,28 +727,63 @@ impl DebugPanel {
}),
)
} else {
this.child(
IconButton::new(
"debug-continue",
IconName::DebugContinue,
)
.icon_size(IconSize::Small)
.disabled(thread_status != ThreadStatus::Stopped)
.on_click(window.listener_for(
running_state,
|this, _, _window, cx| this.continue_thread(cx),
))
.tooltip({
let focus_handle = focus_handle.clone();
move |_window, cx| {
Tooltip::for_action_in(
"Continue Program",
&Continue,
&focus_handle,
cx,
let continue_button = IconButton::new(
"debug-continue",
IconName::DebugContinue,
)
.icon_size(IconSize::Small)
.disabled(thread_status != ThreadStatus::Stopped)
.on_click(window.listener_for(
running_state,
|this, _, _window, cx| {
this.continue_program(cx);
},
))
.tooltip({
let focus_handle = focus_handle.clone();
move |_window, cx| {
Tooltip::for_action_in(
"Continue Program",
&Continue,
&focus_handle,
cx,
)
}
});
this.child(continue_button).when(
capabilities
.supports_single_thread_execution_requests
.unwrap_or_default(),
|this| {
this.child(
IconButton::new(
"debug-continue-thread",
IconName::DebugContinueThread,
)
}
}),
.icon_size(IconSize::Small)
.disabled(
thread_status != ThreadStatus::Stopped,
)
.on_click(window.listener_for(
running_state,
|this, _, _window, cx| {
this.continue_thread(cx);
},
))
.tooltip({
let focus_handle = focus_handle.clone();
move |_window, cx| {
Tooltip::for_action_in(
"Continue Thread",
&ContinueThread,
&focus_handle,
cx,
)
}
}),
)
},
)
}
})
+15 -2
View File
@@ -31,8 +31,10 @@ actions!(
[
/// Starts a new debugging session.
Start,
/// Continues execution until the next breakpoint.
/// Continues all threads until the next breakpoint.
Continue,
/// Continues the selected thread until the next breakpoint.
ContinueThread,
/// Detaches the debugger from the running process.
Detach,
/// Pauses the currently running program.
@@ -159,6 +161,9 @@ pub fn init(cx: &mut App) {
let caps = running_state.capabilities(cx);
let supports_step_back = caps.supports_step_back.unwrap_or_default();
let supports_single_thread_execution_requests = caps
.supports_single_thread_execution_requests
.unwrap_or_default();
let supports_detach = running_state.session().read(cx).is_attached();
let status = running_state.thread_status(cx);
@@ -200,10 +205,18 @@ pub fn init(cx: &mut App) {
let active_item = active_item.clone();
move |_: &Continue, _, cx| {
active_item
.update(cx, |item, cx| item.continue_thread(cx))
.update(cx, |item, cx| item.continue_program(cx))
.ok();
}
})
.when(supports_single_thread_execution_requests, |div| {
let active_item = active_item.clone();
div.on_action(move |_: &ContinueThread, _, cx| {
active_item
.update(cx, |item, cx| item.continue_thread(cx))
.ok();
})
})
})
.when(supports_detach, |div| {
let active_item = active_item.clone();
+10
View File
@@ -1746,6 +1746,16 @@ impl RunningState {
.update(cx, |list, cx| list.schedule_refresh(true, window, cx));
}
pub fn continue_program(&mut self, cx: &mut Context<Self>) {
let Some(thread_id) = self.thread_id else {
return;
};
self.session().update(cx, |state, cx| {
state.continue_program(thread_id, cx);
});
}
pub fn continue_thread(&mut self, cx: &mut Context<Self>) {
let Some(thread_id) = self.thread_id else {
return;
+140 -4
View File
@@ -891,6 +891,142 @@ async fn test_shutdown_parent_session_if_all_children_are_shutdown(
});
}
#[gpui::test]
async fn test_continue_response_updates_thread_statuses(
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
fs.insert_tree(path!("/project"), json!({ "main.rs": "" }))
.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, |client| {
client.on_request::<Continue, _>(move |_, args| {
assert_eq!(args.single_thread, None);
Ok(dap::ContinueResponse {
all_threads_continued: None,
})
});
})
.unwrap();
let client = session.update(cx, |session, _| session.adapter_client().unwrap());
client
.fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
reason: dap::StoppedEventReason::Breakpoint,
description: None,
thread_id: Some(1),
preserve_focus_hint: None,
text: None,
all_threads_stopped: Some(true),
hit_breakpoint_ids: None,
}))
.await;
cx.run_until_parked();
session.update(cx, |session, cx| {
assert_eq!(session.thread_status(ThreadId(1)), ThreadStatus::Stopped);
assert_eq!(session.thread_status(ThreadId(2)), ThreadStatus::Stopped);
session.continue_program(ThreadId(1), cx);
});
cx.run_until_parked();
session.update(cx, |session, _| {
assert_eq!(session.thread_status(ThreadId(1)), ThreadStatus::Running);
assert_eq!(session.thread_status(ThreadId(2)), ThreadStatus::Running);
});
client
.fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
reason: dap::StoppedEventReason::Breakpoint,
description: None,
thread_id: Some(1),
preserve_focus_hint: None,
text: None,
all_threads_stopped: Some(true),
hit_breakpoint_ids: None,
}))
.await;
cx.run_until_parked();
cx.dispatch_action(ContinueThread);
cx.run_until_parked();
session.update(cx, |session, _| {
assert_eq!(session.thread_status(ThreadId(1)), ThreadStatus::Stopped);
assert_eq!(session.thread_status(ThreadId(2)), ThreadStatus::Stopped);
});
}
#[gpui::test]
async fn test_continue_thread_action_only_resumes_selected_thread(
executor: BackgroundExecutor,
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
fs.insert_tree(path!("/project"), json!({ "main.rs": "" }))
.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 continue_requested = Arc::new(AtomicBool::new(false));
let session = start_debug_session(&workspace, cx, {
let continue_requested = continue_requested.clone();
move |client| {
client.on_request::<dap::requests::Initialize, _>(move |_, _| {
Ok(dap::Capabilities {
supports_single_thread_execution_requests: Some(true),
..Default::default()
})
});
client.on_request::<Continue, _>({
let continue_requested = continue_requested.clone();
move |_, args| {
assert_eq!(args.single_thread, Some(true));
continue_requested.store(true, Ordering::SeqCst);
Ok(dap::ContinueResponse {
all_threads_continued: Some(false),
})
}
});
}
})
.unwrap();
let client = session.update(cx, |session, _| session.adapter_client().unwrap());
client
.fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
reason: dap::StoppedEventReason::Breakpoint,
description: None,
thread_id: Some(1),
preserve_focus_hint: None,
text: None,
all_threads_stopped: Some(true),
hit_breakpoint_ids: None,
}))
.await;
cx.run_until_parked();
cx.dispatch_action(ContinueThread);
cx.run_until_parked();
assert!(continue_requested.load(Ordering::SeqCst));
session.update(cx, |session, _| {
assert_eq!(session.thread_status(ThreadId(1)), ThreadStatus::Running);
assert_eq!(session.thread_status(ThreadId(2)), ThreadStatus::Stopped);
});
}
#[gpui::test]
async fn test_debug_panel_item_thread_status_reset_on_failure(
executor: BackgroundExecutor,
@@ -1035,14 +1171,14 @@ async fn test_debug_panel_item_thread_status_reset_on_failure(
for operation in &[
"step_over",
"continue_thread",
"continue_program",
"step_back",
"step_in",
"step_out",
] {
running_state.update(cx, |running_state, cx| match *operation {
"step_over" => running_state.step_over(cx),
"continue_thread" => running_state.continue_thread(cx),
"continue_program" => running_state.continue_program(cx),
"step_back" => running_state.step_back(cx),
"step_in" => running_state.step_in(cx),
"step_out" => running_state.step_out(cx),
@@ -1056,7 +1192,7 @@ async fn test_debug_panel_item_thread_status_reset_on_failure(
.thread_status(cx)
.expect("There should be an active thread selected"),
match *operation {
"continue_thread" => ThreadStatus::Running,
"continue_program" => ThreadStatus::Running,
_ => ThreadStatus::Stepping,
},
"Thread status was not set to correct intermediate state after {} request",
@@ -1079,7 +1215,7 @@ async fn test_debug_panel_item_thread_status_reset_on_failure(
// update state to running, so we can test it actually changes the status back to stopped
running_state
.session()
.update(cx, |session, cx| session.continue_thread(thread_id, cx));
.update(cx, |session, cx| session.continue_program(thread_id, cx));
});
}
}
+1
View File
@@ -91,6 +91,7 @@ pub enum IconName {
Debug,
DebugBreakpoint,
DebugContinue,
DebugContinueThread,
DebugDetach,
DebugDisabledBreakpoint,
DebugDisabledLogBreakpoint,
+58 -6
View File
@@ -1357,7 +1357,7 @@ impl Session {
cx.spawn(async move |this, cx| {
task.await;
this.update(cx, |this, cx| {
this.continue_thread(active_thread_id, cx);
this.continue_program(active_thread_id, cx);
})
})
.detach();
@@ -1526,7 +1526,8 @@ impl Session {
}
Events::Stopped(event) => self.handle_stopped_event(event, cx),
Events::Continued(event) => {
if event.all_threads_continued.unwrap_or_default() {
// DAP defines an omitted `allThreadsContinued` as `true`.
if event.all_threads_continued.unwrap_or(true) {
self.active_snapshot.thread_states.continue_all_threads();
self.breakpoint_store.update(cx, |store, cx| {
store.remove_active_position(Some(self.session_id()), cx)
@@ -2141,6 +2142,38 @@ impl Session {
}
}
fn on_continue_response(
thread_id: ThreadId,
) -> impl FnOnce(
&mut Self,
Result<dap::ContinueResponse>,
&mut Context<Self>,
) -> Option<dap::ContinueResponse>
+ 'static {
move |this, response, cx| match response.log_err() {
Some(response) => {
if response.all_threads_continued.unwrap_or(true) {
this.active_snapshot.thread_states.continue_all_threads();
} else {
this.active_snapshot
.thread_states
.continue_thread(thread_id);
}
this.breakpoint_store.update(cx, |store, cx| {
store.remove_active_position(Some(this.session_id()), cx)
});
this.invalidate_generic();
cx.notify();
Some(response)
}
None => {
this.active_snapshot.thread_states.stop_thread(thread_id);
cx.notify();
None
}
}
}
fn clear_active_debug_line_response(
&mut self,
response: Result<()>,
@@ -2279,11 +2312,30 @@ impl Session {
})
}
pub fn continue_program(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
self.continue_execution(thread_id, None, cx);
}
pub fn continue_thread(&mut self, thread_id: ThreadId, cx: &mut Context<Self>) {
if !self
.capabilities
.supports_single_thread_execution_requests
.unwrap_or_default()
{
return;
}
self.continue_execution(thread_id, Some(true), cx);
}
fn continue_execution(
&mut self,
thread_id: ThreadId,
single_thread: Option<bool>,
cx: &mut Context<Self>,
) {
self.select_historic_snapshot(None, cx);
let supports_single_thread_execution_requests =
self.capabilities.supports_single_thread_execution_requests;
self.active_snapshot
.thread_states
.continue_thread(thread_id);
@@ -2291,10 +2343,10 @@ impl Session {
ContinueCommand {
args: ContinueArguments {
thread_id: thread_id.0,
single_thread: supports_single_thread_execution_requests,
single_thread,
},
},
Self::on_step_response::<ContinueCommand>(thread_id),
Self::on_continue_response(thread_id),
cx,
)
.detach();