diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index b8247433..190e5213 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -2114,6 +2114,16 @@ deduplicates by reference and offset, keeps the set in the session, and sends DA session and never persist or replay it: an address is only valid for one run of one process. +`debug.setBreakpointsMuted { sessionId, muted }` mutes or unmutes every +breakpoint stored for one session and returns the session update. While muted, +Core keeps the stored source, exception, function, instruction and data sets and +keeps accepting replacements, but sends each kind empty, so the adapter stops on +none of them. Toggling resends, after `initialized`, only the kinds that hold an +enabled entry (source paths one by one); repeating the current state sends +nothing. A child session starts muted when its parent is. Clients call it for +every session, including one they start while muted, right after +`debug.createSession` and before its first breakpoint set. + `debug.execute` accepts an optional `granularity` of `statement`, `line`, or `instruction` on `next`, `stepIn`, `stepOut`, and `stepBack`. Core forwards it as the DAP `granularity` argument only when the adapter advertised @@ -3337,6 +3347,23 @@ module selector. Generated fingerprints include both project inputs and the detector revision; either changing marks the detected layer stale and requires regeneration. +`runConfig.decideTitleAction { action, trigger, currentFileChosen, settled, configuration? }` +decides what a press on the title bar's Run or Debug button does. `action` is +`run` or `debug`; `trigger` is `press`, or `settled` when the host asks again for +a press it kept. `settled` says the configurations are read (detection answered, +no model is loading and the configurations were resolved since). `configuration` +describes the configuration the title bar shows, absent when the project has +none: `{ kind: "unsupported" }` (it does not start through a build provider), +`{ kind: "instanceMissing" }`, `{ kind: "modelNotLoaded", failed }` or +`{ kind: "modelLoaded", debuggable }` (its run declares a debug adapter). It +returns `{ kind }`: `currentFile`, `wait` (keep the press until the +configurations are read), `loadModel` (load the instance's model and keep the +press), `unsupported`, `targetMissing`, `modelFailed`, `run`, `debug` or +`notDebuggable`. A model whose last load failed is loaded again only on a press, +so a settled answer that failed again ends with `modelFailed` instead of looping. +Hosts own the loads, the kept press and the notices. +Cases: `rust/fixtures/run-configuration/title-action-v1.json`. + `runConfig.selectContextConfiguration` accepts a provider-supplied `sourcePath` and opaque `symbol`, a `preferredId`, platform path-comparison policy, and an ordered list of effective candidate IDs, identities, disabled state, and @@ -5635,6 +5662,26 @@ message is returned. Transport failures are never treated as a boundary. Addresses are never parsed or offset by Core or hosts. Hosts keep the viewport, scroll anchor and rendering. Cases: `rust/fixtures/debug/disassembly-paging-v1.json`. +### Memory window + +`debug.planMemoryWindow { address }` places the memory view's bounded `readMemory` +window at the address the user typed or the selected frame's instruction pointer. +It returns `{ address, request }`, where `request` is the exact DAP `readMemory` +arguments `{ memoryReference, offset, count }` and `address` is what the address +field shows. An address written as a number (`0x` hexadecimal or decimal, as DAP +writes a `readMemory` reply's `address`) is shown in lowercase `0x` hexadecimal and +read from the 64-byte boundary at or below it, so its byte sits in its row for every +bytes-per-row choice (8, 16, 32 and 64). Any other reference is opaque: it is +trimmed, read from offset 0 and never parsed. Empty, multi-line or overlong +addresses are rejected. `debug.locateMemoryWindow { window, start?, length }` +takes the window unchanged with the reply's `address` and returned byte count and +returns `{ base?, mark? }`: `base` is the first returned byte's address in lowercase +`0x` hexadecimal (rows are labelled with their offset added to it, or with their +offset in the window when it is absent) and `mark` is the index of the byte at the +address read, absent when the reply does not hold it. A window that does not match +its address is rejected. Addresses cross the protocol as strings. Hosts own the +transport, rows, radix and scrolling. Cases: `rust/fixtures/debug/memory-window-v1.json`. + ### Windows source-breakpoint relocation Windows forwards the editor's exact source-preserving edits to diff --git a/rust/fixtures/debug/memory-window-v1.json b/rust/fixtures/debug/memory-window-v1.json new file mode 100644 index 00000000..cec35d9e --- /dev/null +++ b/rust/fixtures/debug/memory-window-v1.json @@ -0,0 +1,78 @@ +{ + "description": "debug.planMemoryWindow places the memory view's readMemory window at an address; debug.locateMemoryWindow finds where each reply's bytes start and which of them is at the address read. A number (0x hexadecimal or decimal) is read from the multiple of 64 at or below it; any other reference is opaque and read from where it points. Addresses are strings because a 64-bit address can exceed the JavaScript safe integer range.", + "scenarios": [ + { + "name": "hexadecimal address inside an aligned row", + "address": "0x2010", + "window": { + "address": "0x2010", + "request": { "memoryReference": "0x2010", "offset": -16, "count": 1024 } + }, + "replies": [ + { "start": "0x2000", "length": 1024, "expected": { "base": "0x2000", "mark": 16 } }, + { "length": 1024, "expected": { "base": "0x2000", "mark": 16 } }, + { "start": "0x2008", "length": 1024, "expected": { "base": "0x2008", "mark": 8 } }, + { "start": "0x2000", "length": 8, "expected": { "base": "0x2000" } }, + { "start": "0x2020", "length": 1024, "expected": { "base": "0x2020" } } + ] + }, + { + "name": "decimal address and decimal reply start", + "address": "8208", + "window": { + "address": "0x2010", + "request": { "memoryReference": "0x2010", "offset": -16, "count": 1024 } + }, + "replies": [ + { "start": "8192", "length": 1024, "expected": { "base": "0x2000", "mark": 16 } } + ] + }, + { + "name": "uppercase prefix, padding and surrounding spaces", + "address": " 0X0000ABCD ", + "window": { + "address": "0xabcd", + "request": { "memoryReference": "0xabcd", "offset": -13, "count": 1024 } + }, + "replies": [ + { "start": "0x000000000000ABC0", "length": 1024, "expected": { "base": "0xabc0", "mark": 13 } } + ] + }, + { + "name": "opaque reference is read from where it points", + "address": "frame:3:ip", + "window": { + "address": "frame:3:ip", + "request": { "memoryReference": "frame:3:ip", "offset": 0, "count": 1024 } + }, + "replies": [ + { "start": "0x7ff0", "length": 1024, "expected": { "base": "0x7ff0", "mark": 0 } }, + { "length": 1024, "expected": { "mark": 0 } }, + { "length": 0, "expected": {} } + ] + }, + { + "name": "address zero", + "address": "0", + "window": { + "address": "0x0", + "request": { "memoryReference": "0x0", "offset": 0, "count": 1024 } + }, + "replies": [ + { "length": 0, "expected": { "base": "0x0" } }, + { "length": 1024, "expected": { "base": "0x0", "mark": 0 } } + ] + }, + { + "name": "highest 64-bit address", + "address": "0xffffffffffffffff", + "window": { + "address": "0xffffffffffffffff", + "request": { "memoryReference": "0xffffffffffffffff", "offset": -63, "count": 1024 } + }, + "replies": [ + { "start": "0xffffffffffffffc0", "length": 64, "expected": { "base": "0xffffffffffffffc0", "mark": 63 } } + ] + } + ] +} diff --git a/rust/fixtures/run-configuration/title-action-v1.json b/rust/fixtures/run-configuration/title-action-v1.json new file mode 100644 index 00000000..7781b2cb --- /dev/null +++ b/rust/fixtures/run-configuration/title-action-v1.json @@ -0,0 +1,217 @@ +{ + "version": 1, + "expected": { "providerNeutral": true, "failedModelLoadsAgainOnlyOnPress": true }, + "description": "runConfig.decideTitleAction: what a press on the title bar's Run or Debug button does, from the facts the host reports. The host keeps a press answered wait or loadModel and asks again with trigger settled once the configurations or the model arrive.", + "cases": [ + { + "name": "current file chosen acts at once, even before the configurations are read", + "request": { + "action": "debug", + "trigger": "press", + "currentFileChosen": true, + "settled": false + }, + "expected": { + "kind": "currentFile" + } + }, + { + "name": "a press before the configurations are read waits", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": false, + "configuration": { + "kind": "modelLoaded", + "debuggable": true + } + }, + "expected": { + "kind": "wait" + } + }, + { + "name": "an answer that leaves the configurations unread keeps waiting", + "request": { + "action": "run", + "trigger": "settled", + "currentFileChosen": false, + "settled": false + }, + "expected": { + "kind": "wait" + } + }, + { + "name": "a project without configurations acts on the current file", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true + }, + "expected": { + "kind": "currentFile" + } + }, + { + "name": "a configuration that does not start through a build provider", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "unsupported" + } + }, + "expected": { + "kind": "unsupported" + } + }, + { + "name": "an undetected build instance", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "instanceMissing" + } + }, + "expected": { + "kind": "targetMissing" + } + }, + { + "name": "an unloaded model is loaded first", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelNotLoaded", + "failed": false + } + }, + "expected": { + "kind": "loadModel" + } + }, + { + "name": "a press loads a model that failed before once more", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelNotLoaded", + "failed": true + } + }, + "expected": { + "kind": "loadModel" + } + }, + { + "name": "a model that fails again ends the waiting press", + "request": { + "action": "run", + "trigger": "settled", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelNotLoaded", + "failed": true + } + }, + "expected": { + "kind": "modelFailed" + } + }, + { + "name": "a model that has not loaded yet after an answer is loaded", + "request": { + "action": "run", + "trigger": "settled", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelNotLoaded", + "failed": false + } + }, + "expected": { + "kind": "loadModel" + } + }, + { + "name": "run with a loaded model", + "request": { + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelLoaded", + "debuggable": false + } + }, + "expected": { + "kind": "run" + } + }, + { + "name": "run once the waited model arrives", + "request": { + "action": "run", + "trigger": "settled", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelLoaded", + "debuggable": true + } + }, + "expected": { + "kind": "run" + } + }, + { + "name": "debug a run that declares a debug adapter", + "request": { + "action": "debug", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelLoaded", + "debuggable": true + } + }, + "expected": { + "kind": "debug" + } + }, + { + "name": "debug a run that declares no debug adapter", + "request": { + "action": "debug", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "configuration": { + "kind": "modelLoaded", + "debuggable": false + } + }, + "expected": { + "kind": "notDebuggable" + } + } + ] +} diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 16cb789d..36561003 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -50,6 +50,9 @@ struct DebugSession { function_breakpoints: Vec, instruction_breakpoints: Vec, data_breakpoints: Vec, + /// Muted breakpoints stay stored but every kind is sent empty, so the adapter stops on + /// none of them; a child starts muted when its parent is. + breakpoints_muted: bool, supports_run_in_terminal_request: bool, pending_run_in_terminal_requests: BTreeMap, supports_start_debugging_request: bool, @@ -137,6 +140,7 @@ pub(crate) fn create_session( "A debug session with this sessionId already exists.", )); } + let mut breakpoints_muted = false; if let Some(parent_id) = &request.parent_session_id { validate_identifier(parent_id, "parentSessionId")?; let parent = sessions @@ -150,6 +154,7 @@ pub(crate) fn create_session( "A child debug session needs a running parent session.", )); } + breakpoints_muted = parent.breakpoints_muted; } let mut session = DebugSession { id: request.session_id.clone(), @@ -176,6 +181,7 @@ pub(crate) fn create_session( function_breakpoints: Vec::new(), instruction_breakpoints: Vec::new(), data_breakpoints: Vec::new(), + breakpoints_muted, supports_run_in_terminal_request: request.supports_run_in_terminal_request, pending_run_in_terminal_requests: BTreeMap::new(), supports_start_debugging_request: request.supports_start_debugging_request, @@ -528,6 +534,26 @@ pub(crate) fn set_data_breakpoints( }) } +/// Mutes or unmutes every breakpoint of one session (`debug.setBreakpointsMuted`). +/// +/// Muting keeps the stored source, exception, function, instruction and data breakpoints +/// and sends each set empty; unmuting sends them as stored again. Only a set holding an +/// enabled breakpoint is resent, since muting does not change what any other set sends. +/// Before DAP initialization the state is only stored and applies to the first sets sent. +pub(crate) fn set_breakpoints_muted( + request: SetBreakpointsMutedRequest, +) -> Result { + with_session(&request.session_id, |session| { + if session.breakpoints_muted != request.muted { + session.breakpoints_muted = request.muted; + if session.did_receive_initialized { + session.resend_enabled_breakpoints()?; + } + } + Ok(session.take_update()) + }) +} + /// Queues one capability-gated variable mutation while execution is paused. pub(crate) fn set_variable( mut request: SetVariableRequest, @@ -1073,6 +1099,57 @@ impl DebugSession { ) } + /// Whether a stored breakpoint with this enabled state goes to the adapter. + fn sends(&self, enabled: bool) -> bool { + enabled && !self.breakpoints_muted + } + + /// Sends again every set holding an enabled breakpoint, after muting changed what + /// those sets send. An adapter is never sent a kind it did not declare. + fn resend_enabled_breakpoints(&mut self) -> Result<(), CoreError> { + if self + .exception_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_exception_breakpoints()?; + } + if self.capabilities.supports_function_breakpoints + && self + .function_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_function_breakpoints()?; + } + if self.capabilities.supports_instruction_breakpoints + && self + .instruction_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_instruction_breakpoints()?; + } + if self.capabilities.supports_data_breakpoints + && self + .data_breakpoints + .iter() + .any(|breakpoint| breakpoint.enabled) + { + self.send_data_breakpoints()?; + } + let sources: Vec = self + .breakpoints + .iter() + .filter(|(_, points)| points.iter().any(|breakpoint| breakpoint.enabled)) + .map(|(path, _)| path.clone()) + .collect(); + for source in sources { + self.send_breakpoints(&source)?; + } + Ok(()) + } + fn send_breakpoints(&mut self, source_path: &str) -> Result<(), CoreError> { let generation = self .breakpoint_generations @@ -1087,7 +1164,7 @@ impl DebugSession { .unwrap_or_default(); let active: Vec = requested .iter() - .filter(|breakpoint| breakpoint.enabled) + .filter(|breakpoint| self.sends(breakpoint.enabled)) .cloned() .collect(); let breakpoints: Vec = active @@ -1129,7 +1206,7 @@ impl DebugSession { let active: Vec<&ExceptionBreakpoint> = self .exception_breakpoints .iter() - .filter(|breakpoint| breakpoint.enabled) + .filter(|breakpoint| self.sends(breakpoint.enabled)) .collect(); let filters: Vec<&str> = active .iter() @@ -1166,7 +1243,7 @@ impl DebugSession { let requested: Vec = self .function_breakpoints .iter() - .filter(|breakpoint| breakpoint.enabled) + .filter(|breakpoint| self.sends(breakpoint.enabled)) .cloned() .collect(); let breakpoints: Vec = requested @@ -1194,7 +1271,7 @@ impl DebugSession { let requested: Vec = self .instruction_breakpoints .iter() - .filter(|breakpoint| breakpoint.enabled) + .filter(|breakpoint| self.sends(breakpoint.enabled)) .cloned() .collect(); let breakpoints: Vec = requested @@ -1226,7 +1303,7 @@ impl DebugSession { let requested: Vec = self .data_breakpoints .iter() - .filter(|breakpoint| breakpoint.enabled) + .filter(|breakpoint| self.sends(breakpoint.enabled)) .cloned() .collect(); let breakpoints: Vec = requested @@ -7083,4 +7160,180 @@ mod tests { Some("features.hotCodeReplace.request") ); } + + fn sent(update: &DebugSessionUpdate) -> Vec<(String, Value)> { + update + .outbound_frames + .iter() + .map(|frame| { + let message = decode_frame(frame); + ( + message["command"].as_str().unwrap_or_default().to_string(), + message["arguments"].clone(), + ) + }) + .collect() + } + + // Muting sends every kind of stored breakpoint empty, the adapter's default exception + // filters included, and unmuting sends them as stored; a child starts muted. + #[test] + fn muting_empties_every_breakpoint_kind_and_children_inherit_it() { + let parent = "debug-mute-parent"; + let child = "debug-mute-child"; + struct Cleanup; + impl Drop for Cleanup { + fn drop(&mut self) { + for session_id in ["debug-mute-child", "debug-mute-parent"] { + // log-exempt: Expected absence if the test already disposed this process-free session. + let _ = destroy_session(SessionRequest { + session_id: session_id.into(), + }); + } + } + } + let _cleanup = Cleanup; + let capabilities = json!({ + "supportsFunctionBreakpoints": true, + "supportsInstructionBreakpoints": true, + "supportsDataBreakpoints": true, + "exceptionBreakpointFilters": [{ + "filter": "uncaught", + "label": "Uncaught Exceptions", + "default": true + }] + }); + create_session(child_request(parent, None)).unwrap(); + receive_messages( + parent, + vec![response_message(1, "initialize", capabilities.clone())], + ); + let line = |line: i64, enabled: bool| SourceBreakpoint { + line, + column: None, + enabled, + condition: None, + hit_condition: None, + log_message: None, + }; + set_breakpoints(SetBreakpointsRequest { + session_id: parent.into(), + source_path: "/workspace/main.c".into(), + breakpoints: vec![line(4, true), line(9, false)], + }) + .unwrap(); + set_breakpoints(SetBreakpointsRequest { + session_id: parent.into(), + source_path: "/workspace/off.c".into(), + breakpoints: vec![line(2, false)], + }) + .unwrap(); + set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: parent.into(), + breakpoints: vec![FunctionBreakpoint { + name: "main".into(), + enabled: true, + condition: None, + hit_condition: None, + }], + }) + .unwrap(); + set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: parent.into(), + breakpoints: vec![InstructionBreakpoint { + instruction_reference: "0x1000".into(), + offset: None, + enabled: true, + condition: None, + hit_condition: None, + }], + }) + .unwrap(); + let configured = receive_messages( + parent, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!(sent(&configured)[0].1["filters"], json!(["uncaught"])); + + let muted = set_breakpoints_muted(SetBreakpointsMutedRequest { + session_id: parent.into(), + muted: true, + }) + .unwrap(); + // The data set and the file holding only a disabled line send the same either way. + assert_eq!( + sent(&muted), + vec![ + ("setExceptionBreakpoints".into(), json!({"filters": []})), + ("setFunctionBreakpoints".into(), json!({"breakpoints": []})), + ( + "setInstructionBreakpoints".into(), + json!({"breakpoints": []}) + ), + ( + "setBreakpoints".into(), + json!({ + "source": {"name": "main.c", "path": "/workspace/main.c"}, + "breakpoints": [], + "sourceModified": false + }) + ), + ] + ); + let again = set_breakpoints_muted(SetBreakpointsMutedRequest { + session_id: parent.into(), + muted: true, + }) + .unwrap(); + assert!(again.outbound_frames.is_empty()); + // A set replaced while muted is stored and sent empty. + let replaced = set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: parent.into(), + breakpoints: vec![FunctionBreakpoint { + name: "worker".into(), + enabled: true, + condition: None, + hit_condition: None, + }], + }) + .unwrap(); + assert_eq!(sent(&replaced)[0].1, json!({"breakpoints": []})); + + create_session(child_request(child, Some(parent))).unwrap(); + receive_messages(child, vec![response_message(1, "initialize", capabilities)]); + set_breakpoints(SetBreakpointsRequest { + session_id: child.into(), + source_path: "/workspace/main.c".into(), + breakpoints: vec![line(4, true)], + }) + .unwrap(); + let child_configured = receive_messages( + child, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + let child_sent = sent(&child_configured); + assert_eq!( + child_sent[0], + ("setExceptionBreakpoints".into(), json!({"filters": []})) + ); + assert!(child_sent + .iter() + .filter(|(command, _)| command == "setBreakpoints") + .all(|(_, arguments)| arguments["breakpoints"] == json!([]))); + + let unmuted = set_breakpoints_muted(SetBreakpointsMutedRequest { + session_id: parent.into(), + muted: false, + }) + .unwrap(); + let unmuted = sent(&unmuted); + assert_eq!(unmuted[0].1["filters"], json!(["uncaught"])); + assert_eq!(unmuted[1].1["breakpoints"][0]["name"], "worker"); + assert_eq!( + unmuted[2].1["breakpoints"][0]["instructionReference"], + "0x1000" + ); + assert_eq!(unmuted[3].1["breakpoints"], json!([{"line": 4}])); + assert_eq!(unmuted.len(), 4); + } } diff --git a/rust/lithe-core/src/debug/memory_window.rs b/rust/lithe-core/src/debug/memory_window.rs new file mode 100644 index 00000000..ea2c9c8d --- /dev/null +++ b/rust/lithe-core/src/debug/memory_window.rs @@ -0,0 +1,255 @@ +//! Placement of the memory view's readMemory window (`debug.planMemoryWindow`, +//! `debug.locateMemoryWindow`). +//! +//! The memory view reads one bounded window at an address the user typed or at +//! the selected frame's instruction pointer, and highlights the byte at that +//! address. An address written as a number, `0x` hexadecimal or decimal as the +//! Debug Adapter Protocol writes the address of a `readMemory` reply, is read +//! from a little before it, on a multiple of the window alignment, so the byte +//! sits in its row as it would in a full dump for every bytes-per-row choice. +//! Any other reference is opaque: it is read from where it points and never +//! parsed. Core owns the window and where the highlighted byte falls in the +//! reply; hosts own the transport, the rows, the radix and the scrolling. +//! +//! Addresses cross the protocol as strings, never JSON numbers, because a +//! 64-bit address can exceed the JavaScript safe integer range. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +/// Bytes one memory window reads. +const MEMORY_WINDOW_BYTES: u32 = 1_024; +/// Alignment of a numeric window's first byte. Every bytes-per-row choice the +/// views offer (8, 16, 32 and 64) divides it, so rows start on their width. +const MEMORY_WINDOW_ALIGN: u64 = 64; +/// Longest address text accepted, the bound Core applies to every memory reference. +const MAX_ADDRESS_CHARS: usize = 256; + +/// Where the memory view reads. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanMemoryWindowRequest { + /// The address the user typed, or the selected frame's instruction pointer reference. + pub(crate) address: String, +} + +/// One readMemory window, echoed by the host with the adapter's reply. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct MemoryWindow { + /// The address read at as the address field shows it: a number in lowercase + /// `0x` hexadecimal, any other reference trimmed and otherwise as given. + pub(crate) address: String, + /// The DAP `readMemory` arguments to send as they are. + pub(crate) request: ReadMemoryArguments, +} + +/// DAP `readMemory` arguments. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReadMemoryArguments { + pub(crate) memory_reference: String, + /// Bytes from `memoryReference` to the window's first byte; zero or negative. + pub(crate) offset: i64, + pub(crate) count: u32, +} + +/// The adapter's reply to one window's `readMemory` request. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LocateMemoryWindowRequest { + pub(crate) window: MemoryWindow, + /// The reply's `address`, where the returned bytes start; absent when the adapter gave none. + #[serde(default)] + pub(crate) start: Option, + /// How many bytes the reply returned. + pub(crate) length: u32, +} + +/// Where the returned bytes sit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MemoryWindowLocation { + /// The first returned byte's address in lowercase `0x` hexadecimal; hosts label each row + /// with its offset added to it. Absent when neither the reply nor the window gives a + /// number, and rows are labelled with their offset in the window. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) base: Option, + /// The index of the byte at the address read; absent when the reply does not hold it. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) mark: Option, +} + +/// Places one window at an address. +pub(crate) fn plan_memory_window( + request: PlanMemoryWindowRequest, +) -> Result { + let address = request.address.trim(); + if address.is_empty() + || address.chars().count() > MAX_ADDRESS_CHARS + || address.chars().any(char::is_control) + { + return Err(invalid( + "A memory address must be a short single-line adapter reference.", + )); + } + Ok(match numeric_address(address) { + Some(value) => { + let address = format!("0x{value:x}"); + MemoryWindow { + request: ReadMemoryArguments { + memory_reference: address.clone(), + // The remainder is below the alignment, so it always fits. + offset: -((value % MEMORY_WINDOW_ALIGN) as i64), + count: MEMORY_WINDOW_BYTES, + }, + address, + } + } + None => MemoryWindow { + address: address.to_string(), + request: ReadMemoryArguments { + memory_reference: address.to_string(), + offset: 0, + count: MEMORY_WINDOW_BYTES, + }, + }, + }) +} + +/// Finds where the bytes of one reply start and which of them is at the address read. +pub(crate) fn locate_memory_window( + request: LocateMemoryWindowRequest, +) -> Result { + let LocateMemoryWindowRequest { + window, + start, + length, + } = request; + if plan_memory_window(PlanMemoryWindowRequest { + address: window.address.clone(), + })? != window + { + return Err(invalid("The memory window does not match its request.")); + } + let target = numeric_address(&window.address); + // The adapter says where the bytes start; without a number for it, they start where the + // window asked. + let base = start + .as_deref() + .and_then(numeric_address) + .or_else(|| target.map(|target| target - window.request.offset.unsigned_abs())); + let index = match (target, base) { + (Some(target), Some(base)) => target.checked_sub(base), + // An opaque reference is read from where it points, so its byte comes first. + (None, _) => Some(0), + (Some(_), None) => None, + }; + Ok(MemoryWindowLocation { + base: base.map(|base| format!("0x{base:x}")), + mark: index + .filter(|index| *index < u64::from(length)) + .and_then(|index| u32::try_from(index).ok()), + }) +} + +/// Reads an address the way the Debug Adapter Protocol writes one: hexadecimal after +/// `0x`, decimal otherwise. Anything else is an opaque reference. +fn numeric_address(text: &str) -> Option { + let text = text.trim(); + match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) { + Some(digits) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_hexdigit()) => { + u64::from_str_radix(digits, 16).ok() + } + Some(_) => None, + None if !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) => text.parse().ok(), + None => None, + } +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../fixtures/debug/memory-window-v1.json" + )) + .unwrap() + } + + // Each scenario plans one window, then locates the bytes of each reply in it. + #[test] + fn memory_windows_match_the_shared_fixture() { + let fixture = fixture(); + for scenario in fixture["scenarios"].as_array().unwrap() { + let name = scenario["name"].as_str().unwrap(); + let window = plan_memory_window(PlanMemoryWindowRequest { + address: scenario["address"].as_str().unwrap().to_string(), + }) + .unwrap(); + assert_eq!( + serde_json::to_value(&window).unwrap(), + scenario["window"], + "{name}: window" + ); + for (index, reply) in scenario["replies"].as_array().unwrap().iter().enumerate() { + let location = locate_memory_window(LocateMemoryWindowRequest { + window: window.clone(), + start: reply["start"].as_str().map(str::to_string), + length: reply["length"].as_u64().unwrap() as u32, + }) + .unwrap(); + assert_eq!( + serde_json::to_value(&location).unwrap(), + reply["expected"], + "{name}: reply {index}" + ); + } + } + } + + #[test] + fn unusable_addresses_are_rejected() { + for address in ["", " ", "0x1\n2", &"7".repeat(MAX_ADDRESS_CHARS + 1)] { + assert!( + plan_memory_window(PlanMemoryWindowRequest { + address: address.to_string(), + }) + .is_err(), + "{address:?}" + ); + } + } + + #[test] + fn a_tampered_window_is_rejected() { + let mut window = plan_memory_window(PlanMemoryWindowRequest { + address: "0x2010".to_string(), + }) + .unwrap(); + window.request.offset = -4; + assert!(locate_memory_window(LocateMemoryWindowRequest { + window, + start: None, + length: 16, + }) + .is_err()); + } + + #[test] + fn numbers_are_read_as_the_protocol_writes_them() { + assert_eq!(numeric_address("0x10"), Some(16)); + assert_eq!(numeric_address("0XfF"), Some(255)); + assert_eq!(numeric_address("4096"), Some(4096)); + assert_eq!(numeric_address("0xffffffffffffffff"), Some(u64::MAX)); + for opaque in ["0x", "0x1g", "+12", "-1", "frame:3", "18446744073709551616"] { + assert_eq!(numeric_address(opaque), None, "{opaque}"); + } + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index eb543db9..130bdfa5 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -7,6 +7,7 @@ mod capabilities; mod disassembly_paging; mod endpoint; mod engine; +mod memory_window; mod projection_v2; mod protocol; mod shortcut; @@ -19,6 +20,7 @@ pub(crate) use breakpoint_relocation::*; pub(crate) use disassembly_paging::{plan_disassembly_window, reduce_disassembly_page}; pub(crate) use endpoint::*; pub(crate) use engine::*; +pub(crate) use memory_window::{locate_memory_window, plan_memory_window}; pub(crate) use shortcut::*; pub(crate) use transport_lifecycle::*; pub(crate) use types::*; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 85271b6e..9e279ef1 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -451,6 +451,14 @@ pub struct SetDataBreakpointsRequest { pub breakpoints: Vec, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Mutes or unmutes every breakpoint stored for one session. +pub struct SetBreakpointsMutedRequest { + pub session_id: String, + pub muted: bool, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Replaces one visible variable value in its adapter-owned parent container. diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index e2a5682e..af5f01ff 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -7,6 +7,7 @@ mod detectors; pub(crate) mod generated_layer; mod provider_run_options; mod provider_runs; +mod title_action; // Toolchain requirement reading for hosts that select a toolchain candidate // without owning Core's in-process generated layer. pub use configuration::{ @@ -19,6 +20,7 @@ pub(crate) use argument_text::{format_argument_text, ArgumentTextRequest}; pub(crate) use configuration::*; pub(crate) use context_selection::{select_context_configuration, ContextSelectionRequest}; pub(crate) use detectors::{scan, DirectoryContext}; +pub(crate) use title_action::{decide_title_action, TitleActionRequest}; pub(crate) mod workspace_scan; pub use workspace_scan::is_run_scan_excluded_directory; diff --git a/rust/lithe-core/src/execution/title_action.rs b/rust/lithe-core/src/execution/title_action.rs new file mode 100644 index 00000000..6a82c161 --- /dev/null +++ b/rust/lithe-core/src/execution/title_action.rs @@ -0,0 +1,151 @@ +//! What a press on the title bar's Run or Debug button does +//! (`runConfig.decideTitleAction`). +//! +//! The title bar runs or debugs the run configuration it shows, or the current +//! file. A press can come before the project's configurations are read, and a +//! provider configuration starts only once its build instance's model is +//! loaded. Core decides from the facts the host reports whether the press acts +//! now, waits for the configurations, loads the model first or ends with a +//! notice. The host owns the loads, keeps the waiting press and shows the +//! notices; once what it waited for has arrived it asks again with trigger +//! `settled`. A model that failed to load is loaded again only on a press, so +//! an answer that fails again ends instead of looping. + +use serde::{Deserialize, Serialize}; + +/// The title bar button pressed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TitleAction { + Run, + Debug, +} + +/// Why the host asks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TitleTrigger { + /// The user pressed the button. + Press, + /// What a waiting press needed has arrived. + Settled, +} + +/// The run configuration the title bar shows, as far as starting it depends on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub(crate) enum TitleConfiguration { + /// The configuration does not start through a build provider. + Unsupported, + /// The build instance the configuration's run belongs to was not detected. + InstanceMissing, + /// The instance's model is not loaded; `failed` says its last load failed. + ModelNotLoaded { failed: bool }, + /// The instance's model is loaded; `debuggable` says the run declares a debug adapter. + ModelLoaded { debuggable: bool }, +} + +/// The facts one decision depends on. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TitleActionRequest { + pub(crate) action: TitleAction, + pub(crate) trigger: TitleTrigger, + /// The user chose Current File over the project's configurations. + pub(crate) current_file_chosen: bool, + /// The configurations are read: detection answered, no model is loading and the + /// configurations were resolved since. + pub(crate) settled: bool, + /// The configuration the title bar shows; absent when the project has none. + #[serde(default)] + pub(crate) configuration: Option, +} + +/// What the host does with the press. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub(crate) enum TitleDecision { + /// Act on the current file. + CurrentFile, + /// Keep the press until the configurations are read. + Wait, + /// Load the instance's model and keep the press until it arrives. + LoadModel, + /// The configuration cannot start from the title bar. + Unsupported, + /// The configuration's build instance was not detected. + TargetMissing, + /// The model failed to load again; show its error. + ModelFailed, + /// Run the configuration. + Run, + /// Debug the configuration. + Debug, + /// The configuration's run declares no debug adapter. + NotDebuggable, +} + +/// Decides what one press does. +pub(crate) fn decide_title_action(request: TitleActionRequest) -> TitleDecision { + if request.current_file_chosen { + return TitleDecision::CurrentFile; + } + if !request.settled { + return TitleDecision::Wait; + } + let Some(configuration) = request.configuration else { + return TitleDecision::CurrentFile; + }; + match configuration { + TitleConfiguration::Unsupported => TitleDecision::Unsupported, + TitleConfiguration::InstanceMissing => TitleDecision::TargetMissing, + TitleConfiguration::ModelNotLoaded { failed: true } + if request.trigger == TitleTrigger::Settled => + { + TitleDecision::ModelFailed + } + TitleConfiguration::ModelNotLoaded { .. } => TitleDecision::LoadModel, + TitleConfiguration::ModelLoaded { debuggable } => match request.action { + TitleAction::Run => TitleDecision::Run, + TitleAction::Debug if debuggable => TitleDecision::Debug, + TitleAction::Debug => TitleDecision::NotDebuggable, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + // Every case is one request and the decision Core must return for it. + #[test] + fn title_actions_match_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../fixtures/run-configuration/title-action-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let request: TitleActionRequest = + serde_json::from_value(case["request"].clone()).unwrap(); + assert_eq!( + serde_json::to_value(decide_title_action(request)).unwrap(), + case["expected"], + "{name}" + ); + } + } + + #[test] + fn unknown_facts_are_rejected() { + let request = serde_json::json!({ + "action": "run", + "trigger": "press", + "currentFileChosen": false, + "settled": true, + "retry": true + }); + assert!(serde_json::from_value::(request).is_err()); + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 86adef87..ace433bd 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -435,6 +435,12 @@ pub enum CoreCommand { DebugDataBreakpointInfo, /// Replaces data breakpoints for one debug session (`debug.setDataBreakpoints`). DebugSetDataBreakpoints, + /// Mutes or unmutes every breakpoint of one debug session (`debug.setBreakpointsMuted`). + DebugSetBreakpointsMuted, + /// Places the memory view's readMemory window at an address (`debug.planMemoryWindow`). + DebugPlanMemoryWindow, + /// Finds the byte read at in one readMemory reply (`debug.locateMemoryWindow`). + DebugLocateMemoryWindow, /// Replaces one visible variable value (`debug.setVariable`). DebugSetVariable, /// Cancels or times out one pending debug operation (`debug.cancelOperation`). @@ -561,6 +567,8 @@ pub enum CoreCommand { RunConfigResolve, /// Selects a configuration for a provider-supplied context identity. RunConfigSelectContextConfiguration, + /// Decides what a press on the title bar's Run or Debug button does (`runConfig.decideTitleAction`). + RunConfigDecideTitleAction, /// Persists editable run options (`runConfig.updateOptions`). RunConfigUpdateOptions, /// Replaces generic local tool preferences while preserving existing Run data. @@ -1009,6 +1017,9 @@ impl CoreCommand { "debug.setInstructionBreakpoints" => Some(Self::DebugSetInstructionBreakpoints), "debug.dataBreakpointInfo" => Some(Self::DebugDataBreakpointInfo), "debug.setDataBreakpoints" => Some(Self::DebugSetDataBreakpoints), + "debug.setBreakpointsMuted" => Some(Self::DebugSetBreakpointsMuted), + "debug.planMemoryWindow" => Some(Self::DebugPlanMemoryWindow), + "debug.locateMemoryWindow" => Some(Self::DebugLocateMemoryWindow), "debug.setVariable" => Some(Self::DebugSetVariable), "debug.cancelOperation" => Some(Self::DebugCancelOperation), "debug.execute" => Some(Self::DebugExecute), @@ -1069,6 +1080,7 @@ impl CoreCommand { "runConfig.selectContextConfiguration" => { Some(Self::RunConfigSelectContextConfiguration) } + "runConfig.decideTitleAction" => Some(Self::RunConfigDecideTitleAction), "runConfig.updateOptions" => Some(Self::RunConfigUpdateOptions), "runConfig.updateToolPreferences" => Some(Self::RunConfigUpdateToolPreferences), "runConfig.saveEditorChanges" => Some(Self::RunConfigSaveEditorChanges), @@ -1290,6 +1302,9 @@ mod tests { "debug.setInstructionBreakpoints", "debug.dataBreakpointInfo", "debug.setDataBreakpoints", + "debug.setBreakpointsMuted", + "debug.planMemoryWindow", + "debug.locateMemoryWindow", "debug.setVariable", "debug.cancelOperation", "debug.execute", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index e1789fc5..2a798810 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1609,6 +1609,24 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { "Invalid debug disassembly page reduction request", crate::debug::reduce_disassembly_page, ), + CoreCommand::DebugPlanMemoryWindow => reducer_response( + id, + parsed.payload, + "Invalid debug memory window request", + crate::debug::plan_memory_window, + ), + CoreCommand::DebugLocateMemoryWindow => reducer_response( + id, + parsed.payload, + "Invalid debug memory window location request", + crate::debug::locate_memory_window, + ), + CoreCommand::DebugSetBreakpointsMuted => reducer_response( + id, + parsed.payload, + "Invalid debug set-breakpoints-muted request", + crate::debug::set_breakpoints_muted, + ), CoreCommand::DebugRelocateBreakpoints => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -2549,6 +2567,20 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::RunConfigDecideTitleAction => { + match serde_json::from_value::(parsed.payload) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::execution::decide_title_action(request)) + .expect("Title action decisions always encode"), + ), + Err(error) => CoreResponse::failure( + id, + CoreError::new(ErrorCode::InvalidRequest, "Invalid title action request") + .with_details(error.to_string()), + ), + } + } CoreCommand::RunConfigSelectContextConfiguration => { match serde_json::from_value::( parsed.payload,