diff --git a/.agents/notes/proposed/architecture/2026-10-25-optional-debug-extensions.md b/.agents/notes/proposed/architecture/2026-10-25-optional-debug-extensions.md index 85940b4a89..d26d599b77 100644 --- a/.agents/notes/proposed/architecture/2026-10-25-optional-debug-extensions.md +++ b/.agents/notes/proposed/architecture/2026-10-25-optional-debug-extensions.md @@ -12,7 +12,7 @@ GDB、LLDB 可以调试 C、C++、Rust 和汇编,但二进制不一定有源码或行号;Java 调试器可以返回反编译源码;js-debug 会发起子会话。这些功能在 DAP 里都是可选的,不同适配器支持的组合各不相同。如果为了显示它们去改必选层的数据结构,或者按适配器名称写分支,每接一个调试器就要改一次宿主。 -现在 Rust Core 已经实现 `setVariable`、各类断点(含数据断点)、`stepBack`、`runInTerminal`;尚未实现 `readMemory`、`writeMemory`、`disassemble`、`setInstructionBreakpoints`、指令粒度单步、`modules`、`loadedSources` 和 `startDebugging`。macOS 的 Node 子会话目前在 Swift 会话里实现。 +现在 Rust Core 已经实现 `setVariable`、各类断点(含数据断点)、`stepBack`、`runInTerminal`,以及原生调试分组里除 `writeMemory` 以外的全部请求(`readMemory`、`disassemble`、`setInstructionBreakpoints`、指令粒度单步,见“原生调试的落地”);尚未实现 `writeMemory`、`modules` 和 `loadedSources`。`startDebugging` 子会话已在宿主与 Core 统一(见统一调试内核笔记)。 ## 提案 @@ -62,6 +62,33 @@ GDB、LLDB 可以调试 C、C++、Rust 和汇编,但二进制不一定有源 第 3 项实际上是 P5 删除旧路径的前置条件:Node 子会话迁到新内核之前,不能删 Swift 会话。 +### 原生调试的落地(macOS) + +寄存器、反汇编、只读内存、指令步进与指令断点按上面的顺序一次落地,`writeMemory` 保持关闭。边界如下: + +- **内核**:`debug.inspect` 新增 `disassemble`、`readMemory` 两个 kind;`debug.execute` 新增可选 `granularity`;新增命令 `debug.setInstructionBreakpoints`。四项适配器能力 `supportsDisassembleRequest`、`supportsReadMemoryRequest`、`supportsSteppingGranularity`、`supportsInstructionBreakpoints` 都已注册为扩展,所以 `initialize` 现在声明 `supportsMemoryReferences`;能力快照里 `supported = 适配器 ∧ 已注册 ∧ 已授权` 不变。栈帧新增 `instructionPointerReference`,没有 `source` 也没有 `sourceReference` 的帧行号为 0,不再补成第 1 行。两个新 kind 都是引用型请求,受 `stopGeneration` 约束,继续执行后到达的旧回复按 `StaleReference` 丢弃。字段细节见 `shared/contracts/rust-core-api.md`。 +- **地址不透明**:宿主任何一层都不把地址解析成整数,也不做加减。反汇编向前后翻页用 `instructionOffset` 加上当前首尾指令的地址;内存分页用相对 `memoryReference` 的字节偏移,行首标签显示偏移而不是算出的地址。判断两个地址写法是否同一个地址,只做文本规范化(小写、去 `0x`、去前导零),所以 `0x0000000100003f50` 与 `0x100003f50` 视为同一个地址,超过 2^53 的地址不会被舍入。 +- **界面**:只有 `supportsDisassembleRequest` 为真才出现“反汇编”标签。选中的栈帧没有源码时标签自动打开,选中有源码的帧时自动关闭;用户自己打开的反汇编不会被自动关闭。标签下方是辅助区:变量、寄存器(只在适配器给出 `presentationHint: "registers"` 的 scope 时出现)、内存(只在 `supportsReadMemoryRequest` 为真时出现)。无源码栈帧的行内显示等宽的指令地址,右键菜单可复制地址、打开反汇编;变量右键可以按其值读取内存。 +- **指令步进**:反汇编标签打开期间,单步(步过)和步入带 `granularity: "instruction"`;工具栏按钮的悬停提示同步变为“按指令单步跳过 / 按指令单步进入”,反汇编头部显示“按指令单步”,当前行随单步最小幅度滚动,不整列表跳动。步出仍按适配器默认处理,因为“跑完当前函数”没有指令粒度的含义。关闭标签后回到适配器默认的按行单步。适配器没有声明 `supportsSteppingGranularity` 时不带该字段,头部改为明确提示“该适配器不支持按指令单步,单步仍按源码行前进”:静默回退会让用户以为是反汇编视图的缺陷。 +- **指令断点**:在反汇编地址栏点击设置或移除,只属于当前会话,不写入持久化,也不在新会话重放:同一段代码下次运行时位于另一个地址(ASLR)。适配器声明 `supportsInstructionBreakpoints` 才显示入口;适配器报告断点未生效时,圆点显示未验证状态,悬停提示是适配器的原文说明。 +- **内存**:输入框接受 `0x…` 地址,或在选中栈帧里求值后得到地址的表达式(寄存器名、保存着指针的变量)。表达式的值不是地址时显示“该表达式的值不是地址”,适配器的错误原样显示。读取失败的尾部字节在界面上标为无法读取,不显示成 00。 +- **内存的显示与定位**:输入地址后首次读取从目标地址前 64 字节开始(`offset: -64`),目标字节所在的十六进制格和 ASCII 字符同时高亮并滚入可见区;适配器不支持负偏移,或前面不是已映射区域(读取失败)时,退回从目标地址读起,不报错。首行标签是相对 `memoryReference` 的偏移,不是算出的地址。右侧的显示设置(进制、每格字节数 1/2/4/8、每行字节数、字节序、是否显示 ASCII)只改变展示,不改变读取请求,也不写入持久化。单步后的静默重读只标出相对上一页变化的字节,不改变滚动位置;翻页也不滚动到目标,只有用户主动读取才滚动到目标字节。 +- **单步时的局部刷新**:`next`、`stepIn`、`stepOut`、`stepBack` 之后,线程、调用栈、变量、反汇编、内存和 JSON 视图保持原样并标为过期(只读,不能编辑变量或 Watch),新的停止到达后再原地替换:先后拉取线程、调用栈、同名作用域、变量,按名称路径恢复已展开的节点,变量树整体提交后才替换旧树,所以不会出现整块清空再重画。`continue`、`pause`、终止和失败仍按原来的方式整体清空;栈为空、没有可用线程或会话结束时放弃保留。选中另一个栈帧不属于单步,不走保留逻辑;此时 JSON 视图会短暂显示上一个快照再更新,这是接受的代价。反汇编列表只在当前指令不在已加载范围内时重新定位,快到列表末尾(剩余 ≤8 行)时向后补页。 +- **取消和只读的时机**:开始保留画面时就冻结变量树,不能等新作用域返回才冻结,否则暂停事件与作用域返回之间会把旧引用恢复成可编辑。取消会同步调用失败回调,所以必须先推进反汇编、内存请求的代次,再取消会话里的请求;失败重试也必须检查代次,不能在切换会话后向旧会话补发请求。传输失败时清空所有保留画面,不把上一个暂停点的数据显示成当前值。内存行偏移相对请求的 `memoryReference`,表头必须显示这个引用,而不是适配器返回的页首 `address`。 +- **仍然关闭**:`writeMemory`。读取能力不代表允许写入,写入需要独立的授权和确认用例,见上面的“写操作单独授权”。 + +### 真实适配器验证(Xcode `lldb-dap`) + +用 `-g` 编译的 C 程序和 Xcode 自带的 `lldb-dap` 实测,结论如下: + +- `initialize` 声明了 `supportsDisassembleRequest`、`supportsReadMemoryRequest`、`supportsSteppingGranularity`、`supportsInstructionBreakpoints`,也声明了 `supportsWriteMemoryRequest`。适配器支持写内存不影响 Lithe 的边界:写入没有注册为扩展,客户端不开放,能力快照里它保持不可用。 +- 在 `main.c:8` 设行断点停住后连续单步:`next` / `stepIn` 带 `granularity: "instruction"` 每次只前进一条指令(arm64 每次 PC +4,源码行仍是 8);`granularity: "line"` 才会走到第 9 行。这说明“看起来还是代码级单步”是界面没有提示造成的,内核发送的粒度本来就对。 +- 没有 `-g` 的二进制里,在函数或文件上设的断点不会绑定;这类程序在 `main` 之前没有可停的位置。要在反汇编里选定位置停下,用带调试信息的程序:先用行断点停住,再点击后面指令的断点栏,然后继续执行。指令断点只属于当前会话,下次运行地址会变(ASLR)。 +- 地址有大小写和前导零的差异(`0x10000049C` 与 `0x10000049c`),所以比较地址只做文本规范化,不做数值运算。 +- 直接 `swift build` 得到的是没有链接 Rust Core 的二进制,调试和打开文件都不可用;本地验证要用 `scripts/build-macos.sh --configuration debug --triple arm64-apple-macosx` 构建,把产物放进开发包并重新签名(开发包标识 `app.lithe.desktop.dev`,数据目录与正式版分开)。 +- 两轴滚动里的文本如果不用 `fixedSize(horizontal: true, vertical: false)`,宽度会塌成 0,反汇编列表整行看起来是空的。 +- JSON 视图顶部的空白条来自复制按钮单独占一行;改成浮在第一行右侧、各行右侧留出按钮宽度,并按键、字符串、数字、关键字分色。 + ### 正确做法 - 新增反汇编:内核注册 `disassemble` 处理器,两端增加视图,入口只看能力快照里的 `supportsDisassembleRequest`。GDB、LLDB 自动获得,插件不改。 diff --git a/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md b/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md index a4e60ce9e4..6234558d70 100644 --- a/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md +++ b/.agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md @@ -151,6 +151,8 @@ macOS 的 P4 纵向验收用 `lldb-dap` 推进:官方 LLDB 插件(`io.github 只贡献调试适配器的插件在 macOS 上安装、更新、卸载免重启,见[插件热激活](../../implemented/architecture/2026-10-26-plugin-live-activation.md)。这是宿主后端能力,不是清单字段,插件契约不变。Windows 的 P2–P4 还没有开始,不属于这一轮。 +可选扩展里的原生调试已在 macOS 落地(无源码栈帧、反汇编、指令步进与指令断点、寄存器、只读内存):它们是内核的扩展请求,不改必选层,也不按适配器名分支,所以 `lldb-dap`、GDB DAP 或其他声明了对应能力的适配器自动获得;`writeMemory` 保持关闭。设计与边界见[可选调试扩展](2026-10-25-optional-debug-extensions.md)。Windows 界面尚未接入。 + #### Java 迁入 Java Support 插件(P5 的最后一块内置路径) Java 是宿主里最后一个内置 Provider(`debugFactories["java"]`、`MacJavaDebugAdapterTransport`、Core 里 `adapter_id == "java"` 的分支)。迁移拆成三个 PR,每个都能单独合并、单独回退: diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 3df02b6ab3..7004dab51d 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -70,6 +70,8 @@ "%lld CPU cores" = "%lld CPU cores"; "%lld affected commits → %lld commits. Core creates a recovery reference before starting." = "%lld affected commits → %lld commits. Core creates a recovery reference before starting."; "%lld bytes" = "%lld bytes"; +"%lld bytes changed" = "%lld bytes changed"; +"%lld bytes could not be read" = "%lld bytes could not be read"; "%lld changed files" = "%lld changed files"; "%lld characters" = "%lld characters"; "%lld characters total" = "%lld characters total"; @@ -125,15 +127,19 @@ "%llds" = "%llds"; ", " = ", "; "0.5 seconds" = "0.5 seconds"; +"1 byte" = "1 byte"; "1 file" = "1 file"; "1 more notification" = "1 more notification"; "1 usage" = "1 usage"; "1.5 seconds" = "1.5 seconds"; +"2 bytes" = "2 bytes"; "2 spaces" = "2 spaces"; "3 seconds" = "3 seconds"; +"4 bytes" = "4 bytes"; "4 spaces" = "4 spaces"; "5 hours" = "5 hours"; "7 days" = "7 days"; +"8 bytes" = "8 bytes"; "8 spaces" = "8 spaces"; "; " = "; "; "A ChangeList with this name already exists." = "A ChangeList with this name already exists."; @@ -242,6 +248,7 @@ "Additional class patterns" = "Additional class patterns"; "Additions" = "Additions"; "Address" = "Address"; +"Address or expression" = "Address or expression"; "Adds Go language-server integration, formatting, running, and test support." = "Adds Go language-server integration, formatting, running, and test support."; "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime." = "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime."; "Adds language-server integration and formatting support." = "Adds language-server integration and formatting support."; @@ -433,6 +440,8 @@ "Before project replacement" = "Before project replacement"; "Before rename" = "Before rename"; "Before restore" = "Before restore"; +"Big Endian" = "Big Endian"; +"Binary" = "Binary"; "Binary files cannot be compared." = "Binary files cannot be compared."; "Branch" = "Branch"; "Branch '%@' tracks '%@', which has a different name. Choose which branch to update." = "Branch '%@' tracks '%@', which has a different name. Choose which branch to update."; @@ -473,6 +482,8 @@ "Built-in" = "Built-in"; "Built-in backgrounds" = "Built-in backgrounds"; "Bypass permissions" = "Bypass permissions"; +"Byte Order" = "Byte Order"; +"Bytes per Row" = "Bytes per Row"; "C# Support" = "C# Support"; "C/C++/Objective-C Support" = "C/C++/Objective-C Support"; "CLI updated to %@" = "CLI updated to %@"; @@ -892,11 +903,13 @@ "Copied" = "Copied"; "Copied files" = "Copied files"; "Copy" = "Copy"; +"Copy Address" = "Copy Address"; "Copy Branch Name" = "Copy Branch Name"; "Copy Commit Hash" = "Copy Commit Hash"; "Copy Conversation" = "Copy Conversation"; "Copy Expression" = "Copy Expression"; "Copy Files" = "Copy"; +"Copy Instruction" = "Copy Instruction"; "Copy JSON" = "Copy JSON"; "Copy Method Name" = "Copy Method Name"; "Copy Name" = "Copy Name"; @@ -1154,6 +1167,7 @@ "Disabled" = "Disabled"; "Disabled in Agent" = "Disabled in Agent"; "Disabled in this workspace" = "Disabled in this workspace"; +"Disassembly" = "Disassembly"; "Discard" = "Discard"; "Discard Block" = "Discard Block"; "Discard Changes" = "Discard Changes"; @@ -1325,6 +1339,7 @@ "Enter an Authorization header with Bearer and your GitHub token." = "Enter an Authorization header with Bearer and your GitHub token."; "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments." = "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments."; "Enter an absolute path that starts with /." = "Enter an absolute path that starts with /."; +"Enter an address, or a register or variable that holds one" = "Enter an address, or a register or variable that holds one"; "Enter model ID" = "Enter model ID"; "Enter the command that starts this server." = "Enter the command that starts this server."; "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated." = "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated."; @@ -1473,6 +1488,7 @@ "Fit to Window" = "Fit to Window"; "Fix the failing checks below before installing." = "Fix the failing checks below before installing."; "Fixup" = "Fixup"; +"Floating Point" = "Floating Point"; "Folder" = "Folder"; "Folder name" = "Folder name"; "Follow Git setting" = "Follow Git setting"; @@ -1608,6 +1624,7 @@ "Header" = "Header"; "Headers" = "Headers"; "Heap Memory" = "Heap Memory"; +"Hexadecimal" = "Hexadecimal"; "Hidden paths" = "Hidden paths"; "Hide %@ tool window" = "Hide %@ tool window"; "Hide API key" = "Hide API key"; @@ -1927,11 +1944,14 @@ "Lithe: fetch submodules" = "Lithe: fetch submodules"; "Lithe: fetch tags" = "Lithe: fetch tags"; "Lithe: prune stale branches" = "Lithe: prune stale branches"; +"Little Endian" = "Little Endian"; "Load" = "Load"; "Load %lld more" = "Load %lld more"; "Load Disk Version" = "Load Disk Version"; "Load More" = "Load More"; "Load Project Model" = "Load Project Model"; +"Load earlier instructions" = "Load earlier instructions"; +"Load later instructions" = "Load later instructions"; "Load more" = "Load more"; "Load more commits" = "Load more commits"; "Load more debugger variables" = "Load more debugger variables"; @@ -1957,6 +1977,7 @@ "Loading dependencies…" = "Loading dependencies…"; "Loading diff…" = "Loading diff…"; "Loading history…" = "Loading history…"; +"Loading instructions…" = "Loading instructions…"; "Loading object fields…" = "Loading object fields…"; "Loading project environment…" = "Loading project environment…"; "Loading pull requests" = "Loading pull requests"; @@ -2086,6 +2107,9 @@ "May connect to the repository server" = "May connect to the repository server"; "Me" = "Me"; "Medium" = "Medium"; +"Memory" = "Memory"; +"Memory address or expression" = "Memory address or expression"; +"Memory display options" = "Memory display options"; "Menu Actions" = "Menu Actions"; "Merge" = "Merge"; "Merge %@ into the current branch. Conflicts may require terminal resolution." = "Merge %@ into the current branch. Conflicts may require terminal resolution."; @@ -2221,6 +2245,7 @@ "Next image" = "Next image"; "Next match" = "Next match"; "Next match (Return)" = "Next match (Return)"; +"Next page" = "Next page"; "Next run" = "Next run"; "No %@ configuration was found on this Mac." = "No %@ configuration was found on this Mac."; "No %@ project was detected in this workspace." = "No %@ project was detected in this workspace."; @@ -2408,14 +2433,17 @@ "Not used yet." = "Not used yet."; "Nothing to push" = "Nothing to push"; "Notifications" = "Notifications"; +"Number Base" = "Number Base"; "OCaml Support" = "OCaml Support"; "OK" = "OK"; "ONE-TIME CODE" = "ONE-TIME CODE"; +"Octal" = "Octal"; "Off" = "Off"; "Off for this project" = "Off for this project"; "Official" = "Official"; "Official MCP Registry" = "Official MCP Registry"; "Official plugin %@ declares a signature policy that does not match the host trust policy." = "Official plugin %@ declares a signature policy that does not match the host trust policy."; +"Offsets relative to %@" = "Offsets relative to %@"; "Older commits are not listed." = "Older commits are not listed."; "Older commits are outside the loaded history" = "Older commits are outside the loaded history"; "On" = "On"; @@ -2664,6 +2692,7 @@ "Previous image" = "Previous image"; "Previous match" = "Previous match"; "Previous match (Shift+Return)" = "Previous match (Shift+Return)"; +"Previous page" = "Previous page"; "Previous session" = "Previous session"; "Primary" = "Primary"; "Primary configuration" = "Primary configuration"; @@ -2789,6 +2818,8 @@ "Read" = "Read"; "Read %lld file" = "Read %lld file"; "Read %lld files" = "Read %lld files"; +"Read Memory at Address" = "Read Memory at Address"; +"Read Memory at Value" = "Read Memory at Value"; "Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "Read, write and search files only in the directories you allow. Requires Node.js 24 or later."; "Read-only" = "Read-only"; "Read-only file preview" = "Read-only file preview"; @@ -2796,6 +2827,7 @@ "Reading Git configuration…" = "Reading Git configuration…"; "Reading changes and recent commits." = "Reading changes and recent commits."; "Reading changes…" = "Reading changes…"; +"Reading memory…" = "Reading memory…"; "Reading project…" = "Reading project…"; "Reading registered checkouts." = "Reading registered checkouts."; "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again." = "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again."; @@ -2852,6 +2884,7 @@ "Regenerate" = "Regenerate"; "Registered for this project in %@." = "Registered for this project in %@."; "Registered with %@" = "Registered with %@"; +"Registers" = "Registers"; "Registers the worktree and prepares its HEAD while leaving its files unchecked out." = "Registers the worktree and prepares its HEAD while leaving its files unchecked out."; "Registration" = "Registration"; "Registry website" = "Registry website"; @@ -2899,6 +2932,7 @@ "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning." = "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning."; "Remove All" = "Remove All"; "Remove Connection" = "Remove Connection"; +"Remove Instruction Breakpoint" = "Remove Instruction Breakpoint"; "Remove Worktree…" = "Remove Worktree…"; "Remove account" = "Remove account"; "Remove file %@" = "Remove file %@"; @@ -2981,6 +3015,7 @@ "Reset (Soft)" = "Reset (Soft)"; "Reset Current Branch to Here…" = "Reset Current Branch to Here…"; "Reset Defaults" = "Reset Defaults"; +"Reset Display Options" = "Reset Display Options"; "Reset Fetch defaults" = "Reset Fetch defaults"; "Reset Font Size" = "Reset Font Size"; "Reset Page Zoom (100%)" = "Reset Page Zoom (100%)"; @@ -3237,6 +3272,7 @@ "Select a file" = "Select a file"; "Select a file to preview" = "Select a file to preview"; "Select a file to review" = "Select a file to review"; +"Select a frame to see its instructions" = "Select a frame to see its instructions"; "Select a history entry" = "Select a history entry"; "Select a plugin" = "Select a plugin"; "Select a project history entry" = "Select a project history entry"; @@ -3281,6 +3317,7 @@ "Session statistics" = "Session statistics"; "Sessions" = "Sessions"; "Set" = "Set"; +"Set Instruction Breakpoint" = "Set Instruction Breakpoint"; "Set Up Agent File Attachments…" = "Set Up Agent File Attachments…"; "Set Value…" = "Set Value…"; "Set Variable Value" = "Set Variable Value"; @@ -3327,6 +3364,7 @@ "Show Dependencies" = "Show Dependencies"; "Show Diff" = "Show Diff"; "Show Diff with Working Tree" = "Show Diff with Working Tree"; +"Show Disassembly" = "Show Disassembly"; "Show Editor Preview" = "Show Editor Preview"; "Show Entry" = "Show Entry"; "Show Git key names" = "Show Git key names"; @@ -3339,6 +3377,7 @@ "Show Results" = "Show Results"; "Show Session Details" = "Show Session Details"; "Show Terminal" = "Show Terminal"; +"Show Text Column" = "Show Text Column"; "Show Toolbar" = "Show Toolbar"; "Show all %lld lines" = "Show all %lld lines"; "Show all changes" = "Show all changes"; @@ -3389,6 +3428,7 @@ "Sign-in timed out. Try again." = "Sign-in timed out. Try again."; "Signals that the changes are ready to merge. A summary is optional." = "Signals that the changes are ready to merge. A summary is optional."; "Signature" = "Signature"; +"Signed Decimal" = "Signed Decimal"; "Signed in through the ACP gateway with the API key from your local configuration." = "Signed in through the ACP gateway with the API key from your local configuration."; "Signed in through the ACP gateway with this provider's API key." = "Signed in through the ACP gateway with this provider's API key."; "Single file" = "Single file"; @@ -3511,9 +3551,12 @@ "Status" = "Status"; "Step %lld of %lld" = "Step %lld of %lld"; "Step Back" = "Step Back"; +"Step Into Instruction" = "Step Into Instruction"; +"Step Over Instruction" = "Step Over Instruction"; "Step into" = "Step into"; "Step out" = "Step out"; "Step over" = "Step over"; +"Stepping by instruction" = "Stepping by instruction"; "Stop" = "Stop"; "Stop %@" = "Stop %@"; "Stop Camera" = "Stop Camera"; @@ -3743,6 +3786,7 @@ "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page."; "The downloaded update failed its checksum verification." = "The downloaded update failed its checksum verification."; "The edit stop changed. Refresh the session before amending." = "The edit stop changed. Refresh the session before amending."; +"The expression does not hold an address" = "The expression does not hold an address"; "The featured MCP catalog could not be loaded." = "The featured MCP catalog could not be loaded."; "The field value does not match its declaration or exceeds its size limit." = "The field value does not match its declaration or exceeds its size limit."; "The field value is invalid or exceeds 1024 UTF-8 bytes." = "The field value is invalid or exceeds 1024 UTF-8 bytes."; @@ -3924,6 +3968,7 @@ "This Markdown document is read-only" = "This Markdown document is read-only"; "This Window" = "This Window"; "This action cannot be undone by Lithe." = "This action cannot be undone by Lithe."; +"This adapter cannot step by instruction; steps still move by line" = "This adapter cannot step by instruction; steps still move by line"; "This adapter does not support hot code replacement." = "This adapter does not support hot code replacement."; "This address cannot be opened here." = "This address cannot be opened here."; "This branch has no commits yet" = "This branch has no commits yet"; @@ -4015,6 +4060,7 @@ "Toggle Tests" = "Toggle Tests"; "Toggle breakpoint at caret" = "Toggle breakpoint at caret"; "Toggle favorite" = "Toggle favorite"; +"Toggle instruction breakpoint" = "Toggle instruction breakpoint"; "Toggle preview" = "Toggle preview"; "Token counts reported by the Agent. Accounting scope depends on the Agent." = "Token counts reported by the Agent. Accounting scope depends on the Agent."; "Token usage" = "Token usage"; @@ -4107,6 +4153,7 @@ "Unpin conversation" = "Unpin conversation"; "Unrestricted access to the internet and any file on your computer" = "Unrestricted access to the internet and any file on your computer"; "Unsaved changes" = "Unsaved changes"; +"Unsigned Decimal" = "Unsigned Decimal"; "Unsigned development package. Release builds of Lithe will not load it." = "Unsigned development package. Release builds of Lithe will not load it."; "Unstage" = "Unstage"; "Unstage all files" = "Unstage all files"; @@ -4221,6 +4268,7 @@ "Valid values are saved as you type." = "Valid values are saved as you type."; "Validating the credential stored in Keychain…" = "Validating the credential stored in Keychain…"; "Value" = "Value"; +"Value Size" = "Value Size"; "Values are masked here but stored as plain text in the Agent's configuration file." = "Values are masked here but stored as plain text in the Agent's configuration file."; "Variable scope" = "Variable scope"; "Variables" = "Variables"; @@ -4366,6 +4414,54 @@ "commit messages" = "commit messages"; "conflict -> %@" = "conflict -> %@"; "debugger.continue" = "Continue"; +"debugger.disassembly.copyAddress" = "Copy Address"; +"debugger.disassembly.copyInstruction" = "Copy Instruction"; +"debugger.disassembly.loadEarlier" = "Load earlier instructions"; +"debugger.disassembly.loadLater" = "Load later instructions"; +"debugger.disassembly.loading" = "Loading instructions…"; +"debugger.disassembly.readMemoryAtAddress" = "Read Memory at Address"; +"debugger.disassembly.readMemoryAtValue" = "Read Memory at Value"; +"debugger.disassembly.removeBreakpoint" = "Remove Instruction Breakpoint"; +"debugger.disassembly.selectFrame" = "Select a frame to see its instructions"; +"debugger.disassembly.setBreakpoint" = "Set Instruction Breakpoint"; +"debugger.disassembly.showDisassembly" = "Show Disassembly"; +"debugger.disassembly.steppingByInstruction" = "Stepping by instruction"; +"debugger.disassembly.stepsByLine" = "This adapter cannot step by instruction; steps still move by line"; +"debugger.disassembly.title" = "Disassembly"; +"debugger.disassembly.toggleBreakpoint" = "Toggle instruction breakpoint"; +"debugger.memory.addressOrExpression" = "Address or expression"; +"debugger.memory.addressOrExpressionLabel" = "Memory address or expression"; +"debugger.memory.bigEndian" = "Big Endian"; +"debugger.memory.byteOrder" = "Byte Order"; +"debugger.memory.bytesPerRow" = "Bytes per Row"; +"debugger.memory.changedBytes" = "%1$lld bytes changed"; +"debugger.memory.displayOptions" = "Memory display options"; +"debugger.memory.enterAddress" = "Enter an address, or a register or variable that holds one"; +"debugger.memory.littleEndian" = "Little Endian"; +"debugger.memory.nextPage" = "Next page"; +"debugger.memory.notAnAddress" = "The expression does not hold an address"; +"debugger.memory.numberBase" = "Number Base"; +"debugger.memory.offsetsRelativeTo" = "Offsets relative to %1$@"; +"debugger.memory.previousPage" = "Previous page"; +"debugger.memory.radix.binary" = "Binary"; +"debugger.memory.radix.floatingPoint" = "Floating Point"; +"debugger.memory.radix.hexadecimal" = "Hexadecimal"; +"debugger.memory.radix.octal" = "Octal"; +"debugger.memory.radix.signedDecimal" = "Signed Decimal"; +"debugger.memory.radix.unsignedDecimal" = "Unsigned Decimal"; +"debugger.memory.reading" = "Reading memory…"; +"debugger.memory.resetDisplay" = "Reset Display Options"; +"debugger.memory.showTextColumn" = "Show Text Column"; +"debugger.memory.size.eight" = "8 bytes"; +"debugger.memory.size.four" = "4 bytes"; +"debugger.memory.size.one" = "1 byte"; +"debugger.memory.size.two" = "2 bytes"; +"debugger.memory.title" = "Memory"; +"debugger.memory.unreadableBytes" = "%1$lld bytes could not be read"; +"debugger.memory.valueSize" = "Value Size"; +"debugger.registers.title" = "Registers"; +"debugger.step.intoInstruction" = "Step Into Instruction"; +"debugger.step.overInstruction" = "Step Over Instruction"; "debugger.threadsAndVariables" = "Threads & Variables"; "detected on this Mac" = "detected on this Mac"; "ditto exited with code %@: %@" = "ditto exited with code %@: %@"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 246161ab78..c758c7d2ae 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -70,6 +70,8 @@ "%lld CPU cores" = "%lld 核 CPU"; "%lld affected commits → %lld commits. Core creates a recovery reference before starting." = "%lld 笔受影响提交 → %lld 笔提交。开始前会创建恢复引用。"; "%lld bytes" = "%lld 字节"; +"%lld bytes changed" = "%lld 字节已变化"; +"%lld bytes could not be read" = "有 %lld 字节无法读取"; "%lld changed files" = "%lld 个文件有更改"; "%lld characters" = "%lld 个字符"; "%lld characters total" = "共 %lld 个字符"; @@ -125,15 +127,19 @@ "%llds" = "%lld秒"; ", " = ","; "0.5 seconds" = "0.5 秒"; +"1 byte" = "1 字节"; "1 file" = "1 个文件"; "1 more notification" = "还有 1 条通知"; "1 usage" = "1 处使用"; "1.5 seconds" = "1.5 秒"; +"2 bytes" = "2 字节"; "2 spaces" = "2 个空格"; "3 seconds" = "3 秒"; +"4 bytes" = "4 字节"; "4 spaces" = "4 个空格"; "5 hours" = "5 小时"; "7 days" = "7 天"; +"8 bytes" = "8 字节"; "8 spaces" = "8 个空格"; "; " = ";"; "A ChangeList with this name already exists." = "已存在同名的更改列表。"; @@ -242,6 +248,7 @@ "Additional class patterns" = "其他类模式"; "Additions" = "新增"; "Address" = "地址"; +"Address or expression" = "地址或表达式"; "Adds Go language-server integration, formatting, running, and test support." = "提供 Go 语言服务器、格式化、运行和测试支持。"; "Adds Java language intelligence through the Eclipse JDT language server, with Lombok, debugging, and test support. Ships its own Java runtime." = "通过 Eclipse JDT 语言服务器提供 Java 代码智能,并支持 Lombok、调试和测试。自带 Java 运行时。"; "Adds language-server integration and formatting support." = "提供语言服务器和格式化支持。"; @@ -433,6 +440,8 @@ "Before project replacement" = "项目替换前"; "Before rename" = "重命名前"; "Before restore" = "恢复前"; +"Big Endian" = "大端序"; +"Binary" = "二进制"; "Binary files cannot be compared." = "无法比较二进制文件。"; "Branch" = "分支"; "Branch '%@' tracks '%@', which has a different name. Choose which branch to update." = "分支“%@”跟踪的是名称不同的“%@”。请选择要更新哪个分支。"; @@ -473,6 +482,8 @@ "Built-in" = "内置"; "Built-in backgrounds" = "内置背景"; "Bypass permissions" = "跳过权限确认"; +"Byte Order" = "字节序"; +"Bytes per Row" = "每行字节数"; "C# Support" = "C# 支持"; "C/C++/Objective-C Support" = "C/C++/Objective-C 支持"; "CLI updated to %@" = "CLI 已更新至 %@"; @@ -892,11 +903,13 @@ "Copied" = "复制"; "Copied files" = "已拷贝文件"; "Copy" = "复制"; +"Copy Address" = "复制地址"; "Copy Branch Name" = "复制分支名称"; "Copy Commit Hash" = "复制提交哈希"; "Copy Conversation" = "复制对话"; "Copy Expression" = "复制表达式"; "Copy Files" = "拷贝"; +"Copy Instruction" = "复制指令"; "Copy JSON" = "复制 JSON"; "Copy Method Name" = "复制方法名"; "Copy Name" = "复制名称"; @@ -1154,6 +1167,7 @@ "Disabled" = "已禁用"; "Disabled in Agent" = "Agent 已禁用"; "Disabled in this workspace" = "此工作区已停用"; +"Disassembly" = "反汇编"; "Discard" = "丢弃"; "Discard Block" = "丢弃代码块"; "Discard Changes" = "丢弃更改"; @@ -1325,6 +1339,7 @@ "Enter an Authorization header with Bearer and your GitHub token." = "请填写 Authorization 请求头,内容为 Bearer 加空格和 GitHub 令牌。"; "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments." = "请输入 HTTP 或 HTTPS API 地址,不能包含账号、查询参数或片段。"; "Enter an absolute path that starts with /." = "请输入以 / 开头的绝对路径。"; +"Enter an address, or a register or variable that holds one" = "输入地址,或保存着地址的寄存器或变量"; "Enter model ID" = "填写模型 ID"; "Enter the command that starts this server." = "请输入启动该服务器的命令。"; "Enter the executable and arguments as in a terminal. Quote paths with spaces. Lithe passes exact arguments without running a shell; pipes and redirects are not evaluated." = "按终端形式输入程序和参数,含空格的路径请加引号。Lithe 直接传递参数,不运行 Shell,也不解释管道或重定向。"; @@ -1473,6 +1488,7 @@ "Fit to Window" = "适应窗口"; "Fix the failing checks below before installing." = "请先解决下方未通过的检测项,再进行安装。"; "Fixup" = "合并并丢弃消息"; +"Floating Point" = "浮点数"; "Folder" = "分组"; "Folder name" = "文件夹名称"; "Follow Git setting" = "遵循 Git 配置"; @@ -1608,6 +1624,7 @@ "Header" = "请求头"; "Headers" = "请求头"; "Heap Memory" = "堆内存"; +"Hexadecimal" = "十六进制"; "Hidden paths" = "隐藏路径"; "Hide %@ tool window" = "隐藏 %@ 工具窗口"; "Hide API key" = "隐藏密钥"; @@ -1927,11 +1944,14 @@ "Lithe: fetch submodules" = "Lithe:获取子模块"; "Lithe: fetch tags" = "Lithe:获取标签"; "Lithe: prune stale branches" = "Lithe:清理失效的远程分支"; +"Little Endian" = "小端序"; "Load" = "加载"; "Load %lld more" = "再加载 %lld 项"; "Load Disk Version" = "加载磁盘版本"; "Load More" = "加载更多"; "Load Project Model" = "加载项目模型"; +"Load earlier instructions" = "加载更早的指令"; +"Load later instructions" = "加载更晚的指令"; "Load more" = "加载更多"; "Load more commits" = "加载更多提交"; "Load more debugger variables" = "加载更多调试变量"; @@ -1957,6 +1977,7 @@ "Loading dependencies…" = "正在加载依赖…"; "Loading diff…" = "正在加载 Diff…"; "Loading history…" = "正在加载历史…"; +"Loading instructions…" = "正在加载指令…"; "Loading object fields…" = "正在加载对象字段…"; "Loading project environment…" = "正在读取项目环境…"; "Loading pull requests" = "正在加载拉取请求"; @@ -2086,6 +2107,9 @@ "May connect to the repository server" = "可能连接仓库服务器"; "Me" = "我"; "Medium" = "中"; +"Memory" = "内存"; +"Memory address or expression" = "内存地址或表达式"; +"Memory display options" = "内存显示选项"; "Menu Actions" = "菜单命令"; "Merge" = "合并"; "Merge %@ into the current branch. Conflicts may require terminal resolution." = "将 %@ 合并到当前分支。如有冲突,可能需要在终端中解决。"; @@ -2221,6 +2245,7 @@ "Next image" = "下一张图片"; "Next match" = "下一个匹配"; "Next match (Return)" = "下一个匹配项(Return)"; +"Next page" = "下一页"; "Next run" = "下次运行"; "No %@ configuration was found on this Mac." = "这台 Mac 上没有找到 %@ 配置。"; "No %@ project was detected in this workspace." = "此工作区未检测到 %@ 工程。"; @@ -2408,14 +2433,17 @@ "Not used yet." = "尚未被使用。"; "Nothing to push" = "没有可推送的内容"; "Notifications" = "通知"; +"Number Base" = "数制"; "OCaml Support" = "OCaml 支持"; "OK" = "好"; "ONE-TIME CODE" = "一次性代码"; +"Octal" = "八进制"; "Off" = "关闭"; "Off for this project" = "已在此项目关闭"; "Official" = "官方"; "Official MCP Registry" = "官方 MCP Registry"; "Official plugin %@ declares a signature policy that does not match the host trust policy." = "官方插件 %@ 声明的签名策略与宿主信任策略不一致。"; +"Offsets relative to %@" = "偏移相对于 %@"; "Older commits are not listed." = "更早的提交未列出。"; "Older commits are outside the loaded history" = "更早的提交不在当前加载的历史范围内"; "On" = "开启"; @@ -2664,6 +2692,7 @@ "Previous image" = "上一张图片"; "Previous match" = "上一个匹配"; "Previous match (Shift+Return)" = "上一个匹配项(Shift+Return)"; +"Previous page" = "上一页"; "Previous session" = "上一次会话"; "Primary" = "主工作区"; "Primary configuration" = "主配置"; @@ -2789,6 +2818,8 @@ "Read" = "读取"; "Read %lld file" = "读取了 %lld 个文件"; "Read %lld files" = "读取了 %lld 个文件"; +"Read Memory at Address" = "读取此地址的内存"; +"Read Memory at Value" = "读取此值指向的内存"; "Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "读写、搜索你允许的目录中的文件。需要 Node.js 24 或更高版本。"; "Read-only" = "只读"; "Read-only file preview" = "只读文件预览"; @@ -2796,6 +2827,7 @@ "Reading Git configuration…" = "正在读取 Git 配置…"; "Reading changes and recent commits." = "正在读取文件更改和最近提交。"; "Reading changes…" = "正在读取更改…"; +"Reading memory…" = "正在读取内存…"; "Reading project…" = "正在读取项目…"; "Reading registered checkouts." = "正在读取 Git 中注册的检出目录。"; "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again." = "从 ~/.claude/settings.json 和 ~/.claude.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。"; @@ -2852,6 +2884,7 @@ "Regenerate" = "重新生成"; "Registered for this project in %@." = "已为该项目的 %@ 注册。"; "Registered with %@" = "已注册到 %@"; +"Registers" = "寄存器"; "Registers the worktree and prepares its HEAD while leaving its files unchecked out." = "注册工作树并设置 HEAD,但不检出文件。"; "Registration" = "注册状态"; "Registry website" = "Registry 网站"; @@ -2899,6 +2932,7 @@ "Remove '%@' and its checkout directory? The branch is kept. If Git refuses because files have changed, you can review a separate force-removal warning." = "删除“%@”及其检出目录?对应分支会保留。如果 Git 因文件已更改而拒绝删除,你可以继续查看单独的强制删除警告。"; "Remove All" = "全部移除"; "Remove Connection" = "移除连接"; +"Remove Instruction Breakpoint" = "移除指令断点"; "Remove Worktree…" = "删除工作树…"; "Remove account" = "移除账号"; "Remove file %@" = "移除文件 %@"; @@ -2981,6 +3015,7 @@ "Reset (Soft)" = "软重置"; "Reset Current Branch to Here…" = "将当前分支重置到这里…"; "Reset Defaults" = "恢复默认值"; +"Reset Display Options" = "恢复默认显示"; "Reset Fetch defaults" = "重置 Fetch 默认行为"; "Reset Font Size" = "重置字号"; "Reset Page Zoom (100%)" = "恢复页面缩放(100%)"; @@ -3237,6 +3272,7 @@ "Select a file" = "选择文件"; "Select a file to preview" = "选择文件以预览"; "Select a file to review" = "选择文件以查看"; +"Select a frame to see its instructions" = "选择一个栈帧以查看其指令"; "Select a history entry" = "选择一条历史记录"; "Select a plugin" = "选择一个插件"; "Select a project history entry" = "选择一条项目历史记录"; @@ -3281,6 +3317,7 @@ "Session statistics" = "会话统计"; "Sessions" = "会话"; "Set" = "设置"; +"Set Instruction Breakpoint" = "设置指令断点"; "Set Up Agent File Attachments…" = "设置 Agent 文件附件…"; "Set Value…" = "设置值…"; "Set Variable Value" = "设置变量值"; @@ -3327,6 +3364,7 @@ "Show Dependencies" = "显示依赖"; "Show Diff" = "显示 Diff"; "Show Diff with Working Tree" = "显示与工作区的 Diff"; +"Show Disassembly" = "显示反汇编"; "Show Editor Preview" = "显示编辑器预览"; "Show Entry" = "显示入口"; "Show Git key names" = "显示 Git 配置键名"; @@ -3339,6 +3377,7 @@ "Show Results" = "显示结果"; "Show Session Details" = "显示会话详情"; "Show Terminal" = "跳到此终端"; +"Show Text Column" = "显示文本列"; "Show Toolbar" = "显示工具栏"; "Show all %lld lines" = "显示全部 %lld 行"; "Show all changes" = "显示所有更改"; @@ -3389,6 +3428,7 @@ "Sign-in timed out. Try again." = "登录超时,请重试。"; "Signals that the changes are ready to merge. A summary is optional." = "表示这些更改已可合并,可选择填写说明。"; "Signature" = "签名"; +"Signed Decimal" = "有符号十进制"; "Signed in through the ACP gateway with the API key from your local configuration." = "使用本机配置中的 API 密钥通过 ACP 网关登录。"; "Signed in through the ACP gateway with this provider's API key." = "使用该服务商的 API 密钥通过 ACP 网关登录。"; "Single file" = "单文件"; @@ -3511,9 +3551,12 @@ "Status" = "状态"; "Step %lld of %lld" = "第 %lld 步,共 %lld 步"; "Step Back" = "回退一步"; +"Step Into Instruction" = "按指令单步进入"; +"Step Over Instruction" = "按指令单步跳过"; "Step into" = "单步进入"; "Step out" = "单步跳出"; "Step over" = "单步跳过"; +"Stepping by instruction" = "按指令单步"; "Stop" = "停止"; "Stop %@" = "停止 %@"; "Stop Camera" = "停止摄像头"; @@ -3743,6 +3786,7 @@ "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "下载的更新未通过 SHA-256 校验,请勿安装。请重试或通过 Release 页面下载。"; "The downloaded update failed its checksum verification." = "下载的更新未通过校验。"; "The edit stop changed. Refresh the session before amending." = "编辑暂停位置已更改。请刷新会话后再修改提交。"; +"The expression does not hold an address" = "该表达式的值不是地址"; "The featured MCP catalog could not be loaded." = "无法加载内置 MCP 精选目录。"; "The field value does not match its declaration or exceeds its size limit." = "字段值不符合声明或超过大小限制。"; "The field value is invalid or exceeds 1024 UTF-8 bytes." = "字段值无效或超过 1024 个 UTF-8 字节。"; @@ -3924,6 +3968,7 @@ "This Markdown document is read-only" = "此 Markdown 文档为只读"; "This Window" = "此窗口"; "This action cannot be undone by Lithe." = "此操作无法在 Lithe 中撤销。"; +"This adapter cannot step by instruction; steps still move by line" = "该适配器不支持按指令单步,单步仍按源码行前进"; "This adapter does not support hot code replacement." = "此适配器不支持热代码替换。"; "This address cannot be opened here." = "无法在此处打开该地址。"; "This branch has no commits yet" = "当前分支尚无提交"; @@ -4015,6 +4060,7 @@ "Toggle Tests" = "切换测试窗口"; "Toggle breakpoint at caret" = "在光标处切换断点"; "Toggle favorite" = "切换收藏"; +"Toggle instruction breakpoint" = "切换指令断点"; "Toggle preview" = "切换预览"; "Token counts reported by the Agent. Accounting scope depends on the Agent." = "Agent 上报的 token 数;统计口径由 Agent 决定。"; "Token usage" = "Token 用量"; @@ -4107,6 +4153,7 @@ "Unpin conversation" = "取消置顶"; "Unrestricted access to the internet and any file on your computer" = "允许访问网络和电脑上的所有文件。"; "Unsaved changes" = "有未保存的修改"; +"Unsigned Decimal" = "无符号十进制"; "Unsigned development package. Release builds of Lithe will not load it." = "未签名的开发包,正式版 Lithe 不会加载。"; "Unstage" = "取消暂存"; "Unstage all files" = "取消暂存所有文件"; @@ -4221,6 +4268,7 @@ "Valid values are saved as you type." = "值合法时立即保存。"; "Validating the credential stored in Keychain…" = "正在验证钥匙串中保存的凭据…"; "Value" = "值"; +"Value Size" = "值宽度"; "Values are masked here but stored as plain text in the Agent's configuration file." = "此处仅做遮蔽显示,值会以明文保存在 Agent 配置文件中。"; "Variable scope" = "变量作用域"; "Variables" = "变量"; @@ -4366,6 +4414,54 @@ "commit messages" = "提交信息"; "conflict -> %@" = "版本冲突 → %@"; "debugger.continue" = "继续"; +"debugger.disassembly.copyAddress" = "复制地址"; +"debugger.disassembly.copyInstruction" = "复制指令"; +"debugger.disassembly.loadEarlier" = "加载更早的指令"; +"debugger.disassembly.loadLater" = "加载更晚的指令"; +"debugger.disassembly.loading" = "正在加载指令…"; +"debugger.disassembly.readMemoryAtAddress" = "读取此地址的内存"; +"debugger.disassembly.readMemoryAtValue" = "读取此值指向的内存"; +"debugger.disassembly.removeBreakpoint" = "移除指令断点"; +"debugger.disassembly.selectFrame" = "选择一个栈帧以查看其指令"; +"debugger.disassembly.setBreakpoint" = "设置指令断点"; +"debugger.disassembly.showDisassembly" = "显示反汇编"; +"debugger.disassembly.steppingByInstruction" = "按指令单步"; +"debugger.disassembly.stepsByLine" = "该适配器不支持按指令单步,单步仍按源码行前进"; +"debugger.disassembly.title" = "反汇编"; +"debugger.disassembly.toggleBreakpoint" = "切换指令断点"; +"debugger.memory.addressOrExpression" = "地址或表达式"; +"debugger.memory.addressOrExpressionLabel" = "内存地址或表达式"; +"debugger.memory.bigEndian" = "大端序"; +"debugger.memory.byteOrder" = "字节序"; +"debugger.memory.bytesPerRow" = "每行字节数"; +"debugger.memory.changedBytes" = "%1$lld 字节已变化"; +"debugger.memory.displayOptions" = "内存显示选项"; +"debugger.memory.enterAddress" = "输入地址,或保存着地址的寄存器或变量"; +"debugger.memory.littleEndian" = "小端序"; +"debugger.memory.nextPage" = "下一页"; +"debugger.memory.notAnAddress" = "该表达式的值不是地址"; +"debugger.memory.numberBase" = "数制"; +"debugger.memory.offsetsRelativeTo" = "偏移相对于 %1$@"; +"debugger.memory.previousPage" = "上一页"; +"debugger.memory.radix.binary" = "二进制"; +"debugger.memory.radix.floatingPoint" = "浮点数"; +"debugger.memory.radix.hexadecimal" = "十六进制"; +"debugger.memory.radix.octal" = "八进制"; +"debugger.memory.radix.signedDecimal" = "有符号十进制"; +"debugger.memory.radix.unsignedDecimal" = "无符号十进制"; +"debugger.memory.reading" = "正在读取内存…"; +"debugger.memory.resetDisplay" = "恢复默认显示"; +"debugger.memory.showTextColumn" = "显示文本列"; +"debugger.memory.size.eight" = "8 字节"; +"debugger.memory.size.four" = "4 字节"; +"debugger.memory.size.one" = "1 字节"; +"debugger.memory.size.two" = "2 字节"; +"debugger.memory.title" = "内存"; +"debugger.memory.unreadableBytes" = "有 %1$lld 字节无法读取"; +"debugger.memory.valueSize" = "值宽度"; +"debugger.registers.title" = "寄存器"; +"debugger.step.intoInstruction" = "按指令单步进入"; +"debugger.step.overInstruction" = "按指令单步跳过"; "debugger.threadsAndVariables" = "线程和变量"; "detected on this Mac" = "本机检测到"; "ditto exited with code %@: %@" = "ditto 以代码 %@ 退出:%@"; diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index 297a679563..949719e152 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -122,6 +122,19 @@ extension RustCoreBridge: DebugProtocolCore { ).get() } + func setDebugInstructionBreakpoints( + sessionID: String, + breakpoints: [DebugInstructionBreakpoint] + ) throws -> DebugCoreUpdate { + try executeResult( + command: "debug.setInstructionBreakpoints", + payload: DebugInstructionBreakpointsPayload( + sessionID: sessionID, + breakpoints: breakpoints + ) + ).get() + } + func debugDataBreakpointInfo( sessionID: String, operationID: String, @@ -197,7 +210,8 @@ extension RustCoreBridge: DebugProtocolCore { command: DebugExecutionCommand, threadID: Int?, targetID: Int?, - singleThread: Bool + singleThread: Bool, + granularity: DebugSteppingGranularity? ) throws -> DebugCoreUpdate { try executeResult( command: "debug.execute", @@ -207,7 +221,8 @@ extension RustCoreBridge: DebugProtocolCore { command: command.rawValue, threadID: threadID, targetID: targetID, - singleThread: singleThread + singleThread: singleThread, + granularity: granularity ), operationID: operationID ).get() @@ -227,7 +242,8 @@ extension RustCoreBridge: DebugProtocolCore { context: String?, sourcePath: String?, line: Int?, - column: Int? + column: Int?, + memory: DebugCoreMemoryQuery? ) throws -> DebugCoreUpdate { try executeResult( command: "debug.inspect", @@ -245,7 +261,12 @@ extension RustCoreBridge: DebugProtocolCore { context: context, sourcePath: sourcePath, line: line, - column: column + column: column, + memoryReference: memory?.reference, + offset: memory?.offset, + instructionOffset: memory?.instructionOffset, + instructionCount: memory?.instructionCount, + resolveSymbols: memory?.resolveSymbols ), operationID: operationID ).get() @@ -484,6 +505,16 @@ private struct DebugFunctionBreakpointsPayload: Encodable { } } +private struct DebugInstructionBreakpointsPayload: Encodable { + let sessionID: String + let breakpoints: [DebugInstructionBreakpoint] + + private enum CodingKeys: String, CodingKey { + case sessionID = "sessionId" + case breakpoints + } +} + private struct DebugDataBreakpointInfoPayload: Encodable { let sessionID: String let operationID: String @@ -542,6 +573,7 @@ private struct DebugExecutePayload: Encodable { let threadID: Int? let targetID: Int? let singleThread: Bool + let granularity: DebugSteppingGranularity? private enum CodingKeys: String, CodingKey { case sessionID = "sessionId" @@ -550,6 +582,7 @@ private struct DebugExecutePayload: Encodable { case threadID = "threadId" case targetID = "targetId" case singleThread + case granularity } } @@ -568,6 +601,11 @@ private struct DebugInspectPayload: Encodable { let sourcePath: String? let line: Int? let column: Int? + let memoryReference: String? + let offset: Int? + let instructionOffset: Int? + let instructionCount: Int? + let resolveSymbols: Bool? private enum CodingKeys: String, CodingKey { case sessionID = "sessionId" @@ -579,6 +617,7 @@ private struct DebugInspectPayload: Encodable { case variableFilter, start, count case expression, context case sourcePath, line, column + case memoryReference, offset, instructionOffset, instructionCount, resolveSymbols } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index 40b782e25c..5db8f8f038 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -660,6 +660,8 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele debugSubscription = Publishers.CombineLatest3(feature.$breakpoints, feature.$selectedFrame, feature.$areBreakpointsMuted) .combineLatest(feature.$state) .combineLatest(feature.$variables, feature.$automaticVariables) + // The editor shows the frozen tree while a step reloads; follow the swap. + .combineLatest(feature.$isRefreshingVariables) .receive(on: DispatchQueue.main) .sink { [weak self, weak model] _ in guard let self, let model else { return } diff --git a/macos/Sources/Lithe/Views/Debug/DebugJSONSyntax.swift b/macos/Sources/Lithe/Views/Debug/DebugJSONSyntax.swift new file mode 100644 index 0000000000..f02f856efb --- /dev/null +++ b/macos/Sources/Lithe/Views/Debug/DebugJSONSyntax.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// Colors one line of a pretty-printed debug JSON snapshot so keys and values read differently. +/// +/// The tokenizer works on a single line because the view renders lines independently (fold +/// state, hover, lazy layout). A string is a key only when a colon follows it, so a folded +/// `"orders" : [ … ],` line still colors correctly. Colors come from the editor palette, so the +/// inspector follows the same theme as source files. +enum DebugJSONSyntax { + enum Kind: Equatable { + case plain + case key + case string + case number + case boolean + case null + case punctuation + } + + struct Token: Equatable { + let text: String + let kind: Kind + } + + static func tokens(_ line: String) -> [Token] { + let characters = Array(line) + var tokens: [Token] = [] + var index = 0 + + func take(_ kind: Kind, from start: Int) { + tokens.append(Token(text: String(characters[start.. AttributedString { + var result = AttributedString() + for token in tokens(line) { + var segment = AttributedString(token.text) + segment.foregroundColor = color(for: token.kind) + result += segment + } + return result + } + + private static func color(for kind: Kind) -> Color { + switch kind { + case .plain: text + case .key: key + case .string: string + case .number: number + case .boolean: boolean + case .null: null + case .punctuation: punctuation + } + } + + private static let text = DiffSyntaxHighlighter.syntaxColor(\.text) + private static let key = DiffSyntaxHighlighter.syntaxColor(\.property) + private static let string = DiffSyntaxHighlighter.syntaxColor(\.string) + private static let number = DiffSyntaxHighlighter.syntaxColor(\.number) + private static let boolean = DiffSyntaxHighlighter.syntaxColor(\.boolean) + private static let null = DiffSyntaxHighlighter.syntaxColor(\.null) + private static let punctuation = DiffSyntaxHighlighter.syntaxColor(\.punctuation) +} diff --git a/macos/Sources/Lithe/Views/Debug/DebugVariableJSONView.swift b/macos/Sources/Lithe/Views/Debug/DebugVariableJSONView.swift index f18e5dba5e..2f425c6b8d 100644 --- a/macos/Sources/Lithe/Views/Debug/DebugVariableJSONView.swift +++ b/macos/Sources/Lithe/Views/Debug/DebugVariableJSONView.swift @@ -19,17 +19,31 @@ struct DebugVariableJSONView: View { let scope: Int? let state: DebugAdapterState let roots: [DebugVariable] + /// A step is in flight or its variables are still being swapped in. + let isStepping: Bool } var body: some View { VStack(alignment: .leading, spacing: 6) { - if loading { + // With a snapshot already on screen a refresh happens behind it, so the spinner + // only covers the first load. + if loading && text.isEmpty { ProgressView("Loading object fields…").controlSize(.small) .font(LitheTheme.uiFont(size: 13)) } if !text.isEmpty { - HStack { - Spacer() + // The copy button floats over the first line instead of taking a row of its own, + // which left a blank band above the JSON; the lines reserve its width on the right. + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(folding.visibleLines(collapsed: collapsedLines)) { line in + DebugJSONLineView(line: line, isCollapsed: collapsedLines.contains(line.id)) { + if !collapsedLines.insert(line.id).inserted { collapsedLines.remove(line.id) } + } + } + } + .padding(.trailing, LitheTheme.Metrics.toolbarIconButtonSize + 4) + .id(scheme) + .overlay(alignment: .topTrailing) { Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) @@ -40,32 +54,34 @@ struct DebugVariableJSONView: View { .help("Copy JSON") .accessibilityLabel("Copy JSON") } - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(folding.visibleLines(collapsed: collapsedLines)) { line in - DebugJSONLineView(line: line, isCollapsed: collapsedLines.contains(line.id)) { - if !collapsedLines.insert(line.id).inserted { collapsedLines.remove(line.id) } - } - } - } - .id(scheme) } } - .padding(10) + .padding(.horizontal, 10) + .padding(.vertical, 6) .task(id: Identity(session: feature.activeSessionID, frame: feature.selectedFrameID, - scope: feature.selectedScopeID, state: feature.state, roots: feature.presentedVariables)) { + scope: feature.selectedScopeID, state: feature.state, roots: feature.presentedVariables, + isStepping: feature.isInspectionStale || feature.isRefreshingVariables)) { let id = UUID() requestID = id - text = "" - collapsedLines = [] - folding = DebugJSONFolding("") + // Stepping keeps the previous snapshot readable until the next stop has been + // loaded; the task runs again once the variables settle. + if feature.isInspectionStale || feature.isRefreshingVariables { return } + if feature.state != .paused { + text = "" + collapsedLines = [] + folding = DebugJSONFolding("") + } loading = feature.state == .paused defer { if requestID == id { loading = false } } guard feature.state == .paused else { return } do { let snapshot = try await feature.variableJSONSnapshot() guard !Task.isCancelled, requestID == id else { return } + let refreshed = DebugJSONFolding(snapshot) + // Blocks the user folded stay folded as long as they still exist. + collapsedLines.formIntersection(refreshed.foldableLines) text = snapshot - folding = DebugJSONFolding(snapshot) + folding = refreshed } catch is CancellationError { } catch { guard !Task.isCancelled, requestID == id else { return } @@ -107,7 +123,7 @@ private struct DebugJSONLineView: View { } else { Color.clear.frame(width: LitheTheme.Tree.iconSize, height: 1) } - Text(DiffSyntaxHighlighter.styled(line.text, fileExtension: "json", highlight: nil)) + Text(DebugJSONSyntax.styled(line.text)) .font(LitheTheme.uiFont(size: 13, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -149,6 +165,9 @@ struct DebugJSONFolding { ends = ranges } + /// Lines that open a block and can therefore be folded. + var foldableLines: Set { Set(ends.keys) } + func visibleLines(collapsed: Set) -> [Line] { var result: [Line] = [] var index = 0 diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift new file mode 100644 index 0000000000..2b92ea14cd --- /dev/null +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift @@ -0,0 +1,682 @@ +import AppKit +import LitheCoreContracts +import LitheDebugModule +import SwiftUI + +/// Keeps the Debug tool window's tab in step with the disassembly model. The model opens by +/// itself on a frame without source, and a nested `ObservableObject` is not observed through +/// the feature model, so this watches it directly. +struct DebugDisassemblyPresentationObserver: View { + @ObservedObject var disassembly: GenericDebugDisassemblyModel + let onChange: (Bool) -> Void + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onChange(of: disassembly.isPresented) { onChange($0) } + .allowsHitTesting(false) + } +} + +/// Hands whether steps currently move by instruction to a view that has to reflect it, such +/// as the step buttons' tooltips, without making the whole Debug panel observe the model. +struct DebugSteppingGranularityReader: View { + @ObservedObject var disassembly: GenericDebugDisassemblyModel + @ViewBuilder let content: (Bool) -> Content + + var body: some View { + content(disassembly.stepsByInstruction) + } +} + +/// The instructions around the selected frame's instruction pointer, with a gutter for +/// instruction breakpoints. Addresses are shown exactly as the adapter wrote them. +struct GenericDebugDisassemblyView: View { + @Environment(\.colorScheme) private var colorScheme + @ObservedObject var disassembly: GenericDebugDisassemblyModel + let readMemory: (String) -> Void + /// The instruction pointer the list was last centred on. + @State private var centeredAddress: String? + + private enum Item: Identifiable { + case symbol(id: String, name: String) + case instruction(DebugDisassembledInstruction) + + var id: String { + switch self { + case .symbol(let id, _): "symbol:" + id + case .instruction(let instruction): instruction.address + } + } + } + + private static let fontSize: CGFloat = 12 + private static let rowHeight: CGFloat = 20 + private static let gutterWidth: CGFloat = 22 + + private var items: [Item] { + var result: [Item] = [] + var lastSymbol: String? + for instruction in disassembly.rows { + if let symbol = instruction.symbol, !symbol.isEmpty, symbol != lastSymbol { + result.append(.symbol(id: instruction.address, name: symbol)) + } + if let symbol = instruction.symbol, !symbol.isEmpty { lastSymbol = symbol } + result.append(.instruction(instruction)) + } + return result + } + + private var characterWidth: CGFloat { + LitheTheme.editorFont(size: Self.fontSize).maximumAdvancement.width + } + + var body: some View { + VStack(spacing: 0) { + header + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var header: some View { + HStack(spacing: 7) { + Text("Disassembly") + .font(LitheTheme.uiFont(size: 13, weight: .regular)) + .foregroundStyle(LitheTheme.Tree.text) + if disassembly.isLoading { + ProgressView().controlSize(.mini) + } + Spacer(minLength: 0) + if disassembly.stepsByInstruction { + Text("Stepping by instruction") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.accent) + } else if disassembly.isPresented { + // Without the adapter's help a step would run a whole source line, which looks + // like a bug in the instruction listing, so say so. + Text("This adapter cannot step by instruction; steps still move by line") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.warning) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.horizontal, 10) + .frame(height: LitheTheme.Debugger.inspectorHeaderHeight) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + @ViewBuilder + private var content: some View { + if let failure = disassembly.loadFailure, disassembly.rows.isEmpty { + failureText(failure) + } else if disassembly.rows.isEmpty { + LitheToolWindowStatusText( + disassembly.isLoading ? "Loading instructions…" : "Select a frame to see its instructions" + ) + } else { + list + } + } + + private func failureText(_ message: String) -> some View { + VStack(spacing: 8) { + Spacer(minLength: 0) + Text(verbatim: message) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.error) + .multilineTextAlignment(.center) + .textSelection(.enabled) + Button("Retry") { disassembly.reload() } + .buttonStyle(.litheNoPress) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.Debugger.value) + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var list: some View { + let width = characterWidth + let addressColumn = CGFloat(disassembly.rows.map(\.address.count).max() ?? 0) * width + let bytesColumn = CGFloat(disassembly.rows.compactMap { $0.instructionBytes?.count }.max() ?? 0) * width + return ScrollViewReader { proxy in + ScrollView([.vertical, .horizontal]) { + LazyVStack(alignment: .leading, spacing: 0) { + loadMoreButton(.before) + ForEach(items) { item in + switch item { + case .symbol(_, let name): + symbolRow(name) + case .instruction(let instruction): + instructionRow( + instruction, addressColumn: addressColumn, bytesColumn: bytesColumn + ) + } + } + loadMoreButton(.after) + } + } + .onAppear { scrollToCurrent(proxy, centered: true) } + // Stepping inside the listing moves the arrow, so the list only scrolls as far as + // it takes to keep the arrow on screen instead of recentring on every step. + .onChange(of: disassembly.currentAddress) { _ in scrollToCurrent(proxy, centered: false) } + // A fresh listing is centred once; loading earlier or later instructions changes the + // first row too but must leave the view where it is. + .onChange(of: disassembly.rows.first?.address) { _ in + if centeredAddress != disassembly.currentAddress { scrollToCurrent(proxy, centered: true) } + } + } + } + + private func scrollToCurrent(_ proxy: ScrollViewProxy, centered: Bool) { + guard let current = disassembly.rows.first(where: { disassembly.isCurrent($0) }) else { return } + centeredAddress = disassembly.currentAddress + proxy.scrollTo(current.address, anchor: centered ? .center : nil) + } + + private func loadMoreButton(_ direction: GenericDebugDisassemblyModel.Direction) -> some View { + Button { + disassembly.loadMore(direction) + } label: { + Text(direction == .before ? "Load earlier instructions" : "Load later instructions") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .disabled(disassembly.isLoading) + } + + private func symbolRow(_ name: String) -> some View { + Text(verbatim: name + ":") + .font(LitheTheme.uiFont(size: Self.fontSize, weight: .semibold, design: .monospaced)) + .foregroundStyle(LitheTheme.Tree.secondaryText) + .padding(.leading, Self.gutterWidth + 10) + .frame(height: Self.rowHeight, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func instructionRow( + _ instruction: DebugDisassembledInstruction, + addressColumn: CGFloat, + bytesColumn: CGFloat + ) -> some View { + let isCurrent = disassembly.isCurrent(instruction) + let breakpoint = disassembly.breakpoint(at: instruction.address) + return HStack(spacing: 10) { + gutter(for: instruction.address, breakpoint: breakpoint, isCurrent: isCurrent) + Text(verbatim: instruction.address) + .foregroundStyle(LitheTheme.Tree.secondaryText) + .frame(width: addressColumn, alignment: .leading) + if bytesColumn > 0 { + Text(verbatim: instruction.instructionBytes ?? "") + .foregroundStyle(LitheTheme.Tree.secondaryText) + .frame(width: bytesColumn, alignment: .leading) + } + // Natural width: inside a two-axis scroll view a flexible Text can be proposed no + // width at all and collapse to nothing, which hid the mnemonic on every row. + Text(verbatim: instruction.instruction) + .foregroundStyle(LitheTheme.Tree.text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + if let location = sourceLocation(instruction) { + Text(verbatim: location) + .foregroundStyle(LitheTheme.Tree.secondaryText) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + Spacer(minLength: 0) + } + .font(LitheTheme.uiFont(size: Self.fontSize, design: .monospaced)) + .padding(.trailing, 10) + .frame(height: Self.rowHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isCurrent ? Color(nsColor: executionLineColor) : Color.clear) + .id(instruction.address) + .litheContextMenu { + var menu: [LitheContextMenuItem] = [] + if disassembly.canSetInstructionBreakpoints { + menu.append( + .action( + breakpoint == nil ? "Set Instruction Breakpoint" : "Remove Instruction Breakpoint", + action: { disassembly.toggleBreakpoint(at: instruction.address) })) + menu.append(.separator) + } + if disassembly.canReadMemory { + menu.append( + .action("Read Memory at Address", action: { readMemory(instruction.address) })) + } + menu.append(.action("Copy Address", action: { copy(instruction.address) })) + menu.append(.action("Copy Instruction", action: { copy(instruction.instruction) })) + return menu + } + } + + private func gutter( + for address: String, + breakpoint: GenericDebugInstructionBreakpoint?, + isCurrent: Bool + ) -> some View { + Button { + disassembly.toggleBreakpoint(at: address) + } label: { + ZStack { + if let breakpoint { + LitheIDEAIcon( + resourcePath: LitheIcons.debuggerBreakpointAssetPath( + enabled: breakpoint.enabled, verified: breakpoint.verified, muted: false), + size: 13, + fallbackSystemImage: breakpoint.enabled ? "circle.fill" : "circle", + preservesOriginalColors: true + ) + } else if isCurrent { + Image(systemName: "arrowtriangle.right.fill") + .font(LitheTheme.uiFont(size: 9)) + .foregroundStyle(LitheTheme.accent) + } + } + .frame(width: Self.gutterWidth, height: Self.rowHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .disabled(!disassembly.canSetInstructionBreakpoints) + .help(breakpoint?.message.map { Text(verbatim: $0) } ?? Text("Toggle instruction breakpoint")) + .accessibilityLabel("Toggle instruction breakpoint") + } + + private func sourceLocation(_ instruction: DebugDisassembledInstruction) -> String? { + guard let url = instruction.sourceURL else { return nil } + guard let line = instruction.line, line > 0 else { return "; " + url.lastPathComponent } + return "; \(url.lastPathComponent):\(line)" + } + + private var executionLineColor: NSColor { + CodeEditorPalette(isDark: colorScheme == .dark, theme: LitheTheme.activeTheme).executionLine + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } +} + +/// Reads debuggee memory at an address or at the value of an expression, a page at a time. +/// Lines are labelled by their byte offset from the address that was asked for. +struct GenericDebugMemoryView: View { + @ObservedObject var disassembly: GenericDebugDisassemblyModel + @Binding var input: String + @AppStorage("lithe.debug.memoryDisplay") private var storedDisplay = "" + + private var display: GenericDebugMemoryDisplay { + get { GenericDebugMemoryDisplay(storedText: storedDisplay) } + nonmutating set { storedDisplay = newValue.storedText } + } + + var body: some View { + VStack(spacing: 0) { + inputRow + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var inputRow: some View { + HStack(spacing: 7) { + LitheTextField("Address or expression", text: $input) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)) + .onSubmit { disassembly.readMemory(from: input) } + .accessibilityLabel("Memory address or expression") + if disassembly.isLoadingMemory { + ProgressView().controlSize(.mini) + } + Button("Read") { disassembly.readMemory(from: input) } + .buttonStyle(.litheNoPress) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.Debugger.value) + .disabled(input.trimmingCharacters(in: .whitespaces).isEmpty || !disassembly.canReadMemory) + if disassembly.memory != nil { + pageButton(.before, symbol: "chevron.up", label: "Previous page") + pageButton(.after, symbol: "chevron.down", label: "Next page") + } + displayMenu + } + .padding(.horizontal, 10) + .frame(height: LitheTheme.Debugger.inspectorHeaderHeight) + .litheWorkbenchSurface(LitheTheme.toolHeader) + } + + /// Number base, value size, row width and byte order, in the manner of Visual Studio's + /// memory window. The choice is kept across sessions and never changes the bytes read. + private var displayMenu: some View { + let current = display + return LitheMenu { + LitheContextMenuItem.submenu("Number Base") { + for radix in GenericDebugMemoryDisplay.Radix.allCases { + LitheContextMenuItem.action( + Self.title(of: radix), + checked: current.radix == radix, + leadingCheckmark: true + ) { display = current.selecting(radix: radix) } + } + } + LitheContextMenuItem.submenu("Value Size") { + for size in GenericDebugMemoryDisplay.unitSizes { + LitheContextMenuItem.action( + Self.title(ofUnitSize: size), + checked: current.unitSize == size, + leadingCheckmark: true + ) { display = current.selecting(unitSize: size) } + } + } + LitheContextMenuItem.submenu("Bytes per Row") { + for size in GenericDebugMemoryDisplay.rowSizes { + LitheContextMenuItem.action( + String(size), + localizesTitle: false, + checked: current.bytesPerRow == size, + leadingCheckmark: true + ) { + var next = current + next.bytesPerRow = size + display = next.normalized() + } + } + } + LitheContextMenuItem.submenu("Byte Order") { + LitheContextMenuItem.action( + "Little Endian", + checked: current.byteOrder == .little, + leadingCheckmark: true + ) { + var next = current + next.byteOrder = .little + display = next + } + LitheContextMenuItem.action( + "Big Endian", + checked: current.byteOrder == .big, + leadingCheckmark: true + ) { + var next = current + next.byteOrder = .big + display = next + } + } + LitheContextMenuItem.separator + LitheContextMenuItem.action( + "Show Text Column", + checked: current.showsText, + leadingCheckmark: true + ) { + var next = current + next.showsText.toggle() + display = next + } + LitheContextMenuItem.action("Reset Display Options", isEnabled: current != .standard) { + display = .standard + } + } label: { + Image(systemName: "slider.horizontal.3") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + } + .litheToolbarIconButton() + .help("Memory display options") + .accessibilityLabel("Memory display options") + } + + private static func title(of radix: GenericDebugMemoryDisplay.Radix) -> String { + switch radix { + case .hexadecimal: "Hexadecimal" + case .unsignedDecimal: "Unsigned Decimal" + case .signedDecimal: "Signed Decimal" + case .octal: "Octal" + case .binary: "Binary" + case .floatingPoint: "Floating Point" + } + } + + private static func title(ofUnitSize size: Int) -> String { + switch size { + case 1: "1 byte" + case 2: "2 bytes" + case 4: "4 bytes" + default: "8 bytes" + } + } + + private func pageButton( + _ direction: GenericDebugDisassemblyModel.Direction, + symbol: String, + label: LocalizedStringKey + ) -> some View { + Button { + disassembly.showMemoryPage(direction) + } label: { + Image(systemName: symbol) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + } + .litheToolbarIconButton() + .help(label) + .accessibilityLabel(label) + .disabled(disassembly.isLoadingMemory) + } + + @ViewBuilder + private var content: some View { + if let failure = disassembly.memoryFailure { + failureText(failure) + } else if let memory = disassembly.memory { + dump(memory) + } else { + LitheToolWindowStatusText( + disassembly.isLoadingMemory + ? "Reading memory…" : "Enter an address, or a register or variable that holds one" + ) + } + } + + private func failureText(_ failure: GenericDebugDisassemblyModel.MemoryFailure) -> some View { + VStack { + Spacer(minLength: 0) + switch failure { + case .unresolvedAddress: + Text("The expression does not hold an address") + case .adapter(let message): + Text(verbatim: message) + } + Spacer(minLength: 0) + } + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.error) + .multilineTextAlignment(.center) + .textSelection(.enabled) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func dump(_ memory: GenericDebugMemoryDump) -> some View { + let layout = display + let rows = layout.rows(of: memory.data) + let changed = disassembly.memoryChangedIndices + let target = Self.targetBytes(of: memory, in: rows) + let labelWidth = CGFloat( + rows.map { Self.offsetLabel(memory.pageOffset + $0.index).count }.max() ?? 0 + ) * Self.characterWidth + let valuesWidth = CGFloat(layout.rowTextWidth) * Self.characterWidth + return VStack(spacing: 0) { + baseStrip(memory, changedCount: changed.count) + ScrollViewReader { proxy in + ScrollView([.vertical, .horizontal]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(rows) { row in + memoryRow( + row, + label: Self.offsetLabel(memory.pageOffset + row.index), + labelWidth: labelWidth, + valuesWidth: valuesWidth, + showsText: layout.showsText, + target: target, + changed: changed) + } + if memory.unreadableBytes > 0 { + Text("\(memory.unreadableBytes) bytes could not be read") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.warning) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight, alignment: .leading) + } + } + } + .onAppear { reveal(in: proxy) } + .onChange(of: disassembly.memoryRevealToken) { _ in reveal(in: proxy) } + .onChange(of: storedDisplay) { _ in reveal(in: proxy) } + } + } + } + + /// The reference the offsets count from, kept outside the scrolling rows so it can never + /// cover the first one, and a count of the bytes that moved since the last stop. + private func baseStrip(_ memory: GenericDebugMemoryDump, changedCount: Int) -> some View { + HStack(spacing: 10) { + Text("Offsets relative to \(memory.reference)") + .foregroundStyle(LitheTheme.Tree.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + Spacer(minLength: 0) + if changedCount > 0 { + Text("\(changedCount) bytes changed") + .foregroundStyle(LitheTheme.error) + .lineLimit(1) + } + } + .font(LitheTheme.uiFont(size: 12, design: .monospaced)) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func memoryRow( + _ row: GenericDebugMemoryDisplay.Row, + label: String, + labelWidth: CGFloat, + valuesWidth: CGFloat, + showsText: Bool, + target: Range?, + changed: Set + ) -> some View { + let rowBytes = row.index ..< row.index + row.text.count + let holdsTarget = target.map { $0.overlaps(rowBytes) } ?? false + return HStack(spacing: 14) { + Text(verbatim: label) + .foregroundStyle(holdsTarget ? LitheTheme.accent : LitheTheme.Tree.secondaryText) + .frame(width: labelWidth, alignment: .leading) + Text(valuesText(row, target: target, changed: changed)) + .foregroundStyle(LitheTheme.Tree.text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .frame(width: valuesWidth, alignment: .leading) + if showsText { + Text(characterText(row, target: target, changed: changed)) + .foregroundStyle(LitheTheme.Debugger.value) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + } + .font(LitheTheme.uiFont(size: 12, design: .monospaced)) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight, alignment: .leading) + .background(holdsTarget ? LitheTheme.accent.opacity(0.10) : Color.clear) + .textSelection(.enabled) + } + + /// The values of one row. The value that holds the searched address is highlighted, and + /// a value with a byte that changed since the last stop is drawn in the error colour. + private func valuesText( + _ row: GenericDebugMemoryDisplay.Row, + target: Range?, + changed: Set + ) -> AttributedString { + var result = AttributedString() + for (position, cell) in row.cells.enumerated() { + if position > 0 { result.append(AttributedString(" ")) } + var piece = AttributedString(cell.text) + let bytes = cell.index ..< cell.index + cell.byteCount + if let target, target.overlaps(bytes) { + piece.backgroundColor = LitheTheme.accent.opacity(0.45) + } + if !changed.isEmpty, bytes.contains(where: changed.contains) { + piece.foregroundColor = LitheTheme.error + } + result.append(piece) + } + return result + } + + private func characterText( + _ row: GenericDebugMemoryDisplay.Row, + target: Range?, + changed: Set + ) -> AttributedString { + var result = AttributedString() + for (position, character) in row.text.enumerated() { + var piece = AttributedString(String(character)) + let byte = row.index + position + if let target, target.contains(byte) { + piece.backgroundColor = LitheTheme.accent.opacity(0.45) + } + if changed.contains(byte) { + piece.foregroundColor = LitheTheme.error + } + result.append(piece) + } + return result + } + + /// The bytes of the value that holds the address that was asked for, if the page has it. + static func targetBytes( + of memory: GenericDebugMemoryDump, + in rows: [GenericDebugMemoryDisplay.Row] + ) -> Range? { + guard let index = memory.targetIndex else { return nil } + for row in rows { + for cell in row.cells where cell.index <= index && index < cell.index + cell.byteCount { + return cell.index ..< cell.index + cell.byteCount + } + } + return nil + } + + /// Brings the searched address to the middle of the view, or the top of the page when + /// the page does not hold it (a neighbouring page, or a read that fell back to offset 0). + private func reveal(in proxy: ScrollViewProxy) { + guard let memory = disassembly.memory else { return } + let rows = display.rows(of: memory.data) + guard let first = rows.first else { return } + if let target = Self.targetBytes(of: memory, in: rows), + let row = rows.last(where: { $0.index <= target.lowerBound }) { + proxy.scrollTo(row.index, anchor: .center) + } else { + proxy.scrollTo(first.index, anchor: .top) + } + } + + private static let rowHeight: CGFloat = 20 + + private static var characterWidth: CGFloat { + LitheTheme.editorFont(size: 12).maximumAdvancement.width + } + + /// `+0x0010` relative to the address that was asked for; the host never adds to an address. + static func offsetLabel(_ offset: Int) -> String { + String(format: "%@0x%04llx", offset < 0 ? "-" : "+", UInt64(abs(offset))) + } +} diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 43f831a323..f9ad5cd8a5 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -25,6 +25,9 @@ struct GenericDebugView: View { @State private var isJavaAttachPresented = false @State private var isJavaSteppingSettingsPresented = false @State private var selectedContent: DebugContent = .console + @State private var showsMemory = false + @State private var memoryInput = "" + @State private var disassemblyAuxiliaryHeight: CGFloat? @State private var consoleWrapsLines = false @State private var consolePresentation = ConsoleFoldPresentation() @@ -40,6 +43,15 @@ struct GenericDebugView: View { } .litheWorkbenchSurface(LitheTheme.editor) .background(LitheToolWindowActivityTracker(isActive: $debugToolActive)) + .background( + DebugDisassemblyPresentationObserver(disassembly: feature.disassembly) { presented in + if presented { + selectedContent = .disassembly + } else if selectedContent == .disassembly { + selectedContent = .debugger + } + } + ) .sheet(item: $editingVariable) { variable in VariableValueEditorView(variable: variable) { feature.setVariable(variable, value: $0) @@ -66,8 +78,9 @@ struct GenericDebugView: View { synchronizePerformanceSession() switch state { case .paused: - selectedContent = .debugger + selectedContent = feature.disassembly.isPresented ? .disassembly : .debugger case .initializing, .launching, .failed: + feature.disassembly.dismiss() selectedContent = .console default: break @@ -86,16 +99,35 @@ struct GenericDebugView: View { switch selectedContent { case .debugger: inspector + case .disassembly: + disassemblyInspector case .console: debugConsole } } + private var visibleContents: [DebugContent] { + DebugContent.allCases.filter { + $0 != .disassembly || feature.capabilities.supportsDisassembleRequest + } + } + + /// The disassembly is on screen only while its tab is, so stepping by instruction + /// follows what the user is looking at. + private func select(_ content: DebugContent) { + if content == .disassembly { + feature.disassembly.present() + } else { + feature.disassembly.dismiss() + } + selectedContent = content + } + private var contentTabs: some View { HStack(spacing: 0) { - ForEach(DebugContent.allCases) { content in + ForEach(visibleContents) { content in Button { - selectedContent = content + select(content) } label: { Text(content.title) .font(LitheTheme.uiFont(size: 13)) @@ -248,8 +280,24 @@ struct GenericDebugView: View { } } + @ViewBuilder private func debugToolbarActionButton(_ action: DebugToolbarActionID) -> some View { + if action == .stepOver || action == .stepInto { + // Only the two buttons whose meaning changes watch the disassembly model. + DebugSteppingGranularityReader(disassembly: feature.disassembly) { stepsByInstruction in + debugToolbarActionButton(action, stepsByInstruction: stepsByInstruction) + } + } else { + debugToolbarActionButton(action, stepsByInstruction: false) + } + } + + private func debugToolbarActionButton( + _ action: DebugToolbarActionID, + stepsByInstruction: Bool + ) -> some View { let isDisabled = isDebugToolbarActionDisabled(action) + let help = debugToolbarActionHelp(action, stepsByInstruction: stepsByInstruction) return Button { performDebugToolbarAction(action) } label: { @@ -268,8 +316,8 @@ struct GenericDebugView: View { .buttonStyle(LitheIconButtonStyle(size: LitheTheme.Metrics.toolbarIconButtonSize, disabledOpacity: 1, cornerRadius: 4, hoverBackground: LitheTheme.toolbarHoverBackground, pressedBackground: LitheTheme.toolbarPressedBackground)) .padding(.horizontal, 2) .disabled(isDisabled) - .help(debugToolbarActionHelp(action)) - .accessibilityLabel(debugToolbarActionHelp(action)) + .help(help) + .accessibilityLabel(help) .accessibilityIdentifier("debug-toolbar-\(action.rawValue)") } @@ -325,7 +373,10 @@ struct GenericDebugView: View { } } - private func debugToolbarActionHelp(_ action: DebugToolbarActionID) -> String { + private func debugToolbarActionHelp( + _ action: DebugToolbarActionID, + stepsByInstruction: Bool + ) -> String { let title: String switch action { case .restartOrStart: @@ -335,8 +386,10 @@ struct GenericDebugView: View { case .stop: title = L10n.string("Stop debugging") case .resume: title = L10n.string("Resume") case .pause: title = L10n.string("Pause") - case .stepOver: title = L10n.string("Step over") - case .stepInto: title = L10n.string("Step into") + case .stepOver: + title = stepsByInstruction ? L10n.string("Step Over Instruction") : L10n.string("Step over") + case .stepInto: + title = stepsByInstruction ? L10n.string("Step Into Instruction") : L10n.string("Step into") case .stepOut: title = L10n.string("Step out") case .viewBreakpoints: title = L10n.string("View breakpoints") case .muteBreakpoints: @@ -454,21 +507,113 @@ struct GenericDebugView: View { } private var inspector: some View { - GeometryReader { geometry in + splitInspector { dataInspector } + } + + private func splitInspector(@ViewBuilder _ content: () -> Content) -> some View { + let data = content() + return GeometryReader { geometry in LitheSplitPaneView(axis: .horizontal, placement: .leading, defaultSize: inspectorWidth ?? geometry.size.width / 3, minimum: 240, maximum: max(240, geometry.size.width - 300), flexibleMinimum: 300, dividerColor: LitheTheme.toolWindowBorder(for: colorScheme), onCommit: { inspectorWidth = $0 }) { executionInspector + .disabled(feature.isInspectionStale || feature.isRefreshingVariables) + .opacity(feature.isInspectionStale || feature.isRefreshingVariables ? 0.6 : 1) } flexible: { - dataInspector + data + .disabled(feature.isInspectionStale || feature.isRefreshingVariables) + .opacity(feature.isInspectionStale || feature.isRefreshingVariables ? 0.6 : 1) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .litheWorkbenchSurface(LitheTheme.sidebar) } + private var disassemblyInspector: some View { + splitInspector { disassemblyWorkspace } + } + + private var disassemblyWorkspace: some View { + GeometryReader { geometry in + LitheSplitPaneView(axis: .vertical, placement: .trailing, + defaultSize: disassemblyAuxiliaryHeight ?? max(100, geometry.size.height * 0.38), + minimum: 100, maximum: max(100, geometry.size.height - 120), flexibleMinimum: 120, + dividerColor: LitheTheme.toolWindowBorder(for: colorScheme), + onCommit: { disassemblyAuxiliaryHeight = $0 }) { + disassemblyAuxiliaryPane + } flexible: { + GenericDebugDisassemblyView(disassembly: feature.disassembly, readMemory: showMemory) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .litheWorkbenchSurface(LitheTheme.sidebar) + } + + private var registersScope: DebugScope? { + feature.scopes.first { $0.isRegisters } + } + + private var selectedScopeIsRegisters: Bool { + feature.scopes.first { $0.id == feature.selectedScopeID }?.isRegisters == true + } + + private var disassemblyAuxiliaryPane: some View { + VStack(spacing: 0) { + HStack(spacing: 14) { + auxiliaryTab("Variables", isSelected: !showsMemory && !selectedScopeIsRegisters) { + showsMemory = false + if selectedScopeIsRegisters, + let scope = feature.scopes.first(where: { !$0.isRegisters }) { + feature.selectScope(scope) + } + } + if let registers = registersScope { + auxiliaryTab("Registers", isSelected: !showsMemory && selectedScopeIsRegisters) { + showsMemory = false + feature.selectScope(registers) + } + } + if feature.disassembly.canReadMemory { + auxiliaryTab("Memory", isSelected: showsMemory) { showsMemory = true } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: LitheTheme.Debugger.inspectorHeaderHeight) + .litheWorkbenchSurface(LitheTheme.toolHeader) + divider + if showsMemory && feature.disassembly.canReadMemory { + GenericDebugMemoryView(disassembly: feature.disassembly, input: $memoryInput) + } else { + variablesInspector + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func auxiliaryTab( + _ title: LocalizedStringKey, + isSelected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(LitheTheme.uiFont(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + } + + /// Reads memory at an address taken from the disassembly or from a register value. + private func showMemory(at address: String) { + memoryInput = address + showsMemory = true + feature.disassembly.readMemory(from: address) + } + private var executionInspector: some View { VStack(spacing: 0) { HStack(spacing: 0) { @@ -536,6 +681,12 @@ struct GenericDebugView: View { .foregroundStyle(LitheTheme.Tree.secondaryText) .lineLimit(1) .truncationMode(.middle) + } else if frame.isSourceless, let address = frame.instructionPointerReference { + Text(verbatim: address) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.Tree.secondaryText) + .lineLimit(1) + .truncationMode(.middle) } } .opacity(frame.isFiltered ? 0.58 : 1) @@ -547,6 +698,19 @@ struct GenericDebugView: View { action: { copyToPasteboard(frame.name) })) + if let address = frame.instructionPointerReference { + items.append( + .action("Copy Address", action: { copyToPasteboard(address) })) + if feature.capabilities.supportsDisassembleRequest { + items.append( + .action( + "Show Disassembly", + action: { + feature.selectFrame(frame) + select(.disassembly) + })) + } + } if let sourceURL = frame.sourceURL { items.append(.separator) items.append( @@ -817,6 +981,18 @@ struct GenericDebugView: View { feature.requestDataBreakpoint(for: variable) })) } + if feature.disassembly.canReadMemory, + let pointer = DebugAddressText.firstHexLiteral(in: variable.value) + { + items.append( + .action( + "Read Memory at Value", + action: { + selectedContent = .disassembly + feature.disassembly.present() + showMemory(at: pointer) + })) + } items.append(.separator) items.append(.action("Copy Value", action: { copyToPasteboard(variable.value) })) items.append( @@ -934,6 +1110,7 @@ struct GenericDebugView: View { for scope in feature.scopes { LitheContextMenuItem.action( scope.name, + systemImage: scope.isRegisters ? "cpu" : nil, checked: feature.selectedScopeID == scope.id ) { feature.selectScope(scope) @@ -1928,6 +2105,7 @@ struct DebugBreakpointManagerView: View { private enum DebugContent: CaseIterable, Identifiable { case debugger + case disassembly case console var id: Self { self } @@ -1935,6 +2113,7 @@ private enum DebugContent: CaseIterable, Identifiable { var title: LocalizedStringKey { switch self { case .debugger: "Threads & Variables" + case .disassembly: "Disassembly" case .console: "Console" } } diff --git a/macos/Sources/Lithe/Views/Diff/DiffSyntaxHighlighter.swift b/macos/Sources/Lithe/Views/Diff/DiffSyntaxHighlighter.swift index 5048242c77..fafb4e7968 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffSyntaxHighlighter.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffSyntaxHighlighter.swift @@ -10,7 +10,7 @@ enum DiffSyntaxHighlighter { } // Reuse the editor's configured syntax colors instead of UI status colors. - private static func syntaxColor(_ keyPath: KeyPath) -> Color { + static func syntaxColor(_ keyPath: KeyPath) -> Color { Color(nsColor: NSColor(name: nil) { appearance in let dark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let base = CodeEditorPalette(isDark: dark, theme: LitheTheme.activeTheme) diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 3cbfa82632..d7e5a1bb16 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -326,6 +326,8 @@ public struct DebugBreakpoint: Identifiable, Equatable, Sendable { public let message: String? public let functionName: String? public let dataID: String? + /// Address of an instruction breakpoint; opaque, never parsed by the host. + public let instructionReference: String? public let sourceURL: URL? public let requestedLine: Int? public let line: Int? @@ -340,13 +342,15 @@ public struct DebugBreakpoint: Identifiable, Equatable, Sendable { column: Int?, functionName: String? = nil, dataID: String? = nil, - requestedLine: Int? = nil + requestedLine: Int? = nil, + instructionReference: String? = nil ) { self.id = id self.verified = verified self.message = message self.functionName = functionName self.dataID = dataID + self.instructionReference = instructionReference self.sourceURL = sourceURL self.requestedLine = requestedLine self.line = line @@ -416,6 +420,78 @@ public struct DebugFunctionBreakpoint: Codable, Hashable, Sendable { } } +/// A breakpoint on one machine instruction. `instructionReference` is the opaque +/// address string the adapter reported; 64-bit addresses are never parsed or added to. +public struct DebugInstructionBreakpoint: Codable, Hashable, Sendable { + public let instructionReference: String + public let offset: Int? + public let enabled: Bool + public let condition: String? + public let hitCondition: String? + + public init( + instructionReference: String, + offset: Int? = nil, + enabled: Bool = true, + condition: String? = nil, + hitCondition: String? = nil + ) { + self.instructionReference = instructionReference + self.offset = offset + self.enabled = enabled + self.condition = condition + self.hitCondition = hitCondition + } +} + +/// How far one step moves, in the DAP `SteppingGranularity` vocabulary. +public enum DebugSteppingGranularity: String, Codable, Equatable, Sendable { + case statement, line, instruction +} + +/// One machine instruction. `address` and `instructionBytes` stay adapter-formatted +/// strings so they are shown exactly as reported. +public struct DebugDisassembledInstruction: Identifiable, Equatable, Sendable { + public let address: String + public let instructionBytes: String? + public let instruction: String + /// Symbol the instruction belongs to; reported only on the first instruction of it. + public let symbol: String? + public let sourceURL: URL? + public let line: Int? + + public var id: String { address } + + public init( + address: String, + instructionBytes: String?, + instruction: String, + symbol: String? = nil, + sourceURL: URL? = nil, + line: Int? = nil + ) { + self.address = address + self.instructionBytes = instructionBytes + self.instruction = instruction + self.symbol = symbol + self.sourceURL = sourceURL + self.line = line + } +} + +/// Raw debuggee bytes returned by a read-only memory request. +public struct DebugMemoryRead: Equatable, Sendable { + public let address: String + public let unreadableBytes: Int + public let data: Data + + public init(address: String, unreadableBytes: Int, data: Data) { + self.address = address + self.unreadableBytes = unreadableBytes + self.data = data + } +} + public struct DebugDataBreakpoint: Codable, Hashable, Sendable { public let dataID: String public let label: String? @@ -619,6 +695,10 @@ public struct DebugAdapterCapabilities: Equatable, Sendable { public let supportsStepInTargetsRequest: Bool public let supportsGotoTargetsRequest: Bool public let supportsCompletionsRequest: Bool + public let supportsDisassembleRequest: Bool + public let supportsSteppingGranularity: Bool + public let supportsInstructionBreakpoints: Bool + public let supportsReadMemoryRequest: Bool public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] public static let unknown = DebugAdapterCapabilities() @@ -643,6 +723,10 @@ public struct DebugAdapterCapabilities: Equatable, Sendable { supportsStepInTargetsRequest: Bool = false, supportsGotoTargetsRequest: Bool = false, supportsCompletionsRequest: Bool = false, + supportsDisassembleRequest: Bool = false, + supportsSteppingGranularity: Bool = false, + supportsInstructionBreakpoints: Bool = false, + supportsReadMemoryRequest: Bool = false, exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] = [] ) { self.negotiated = negotiated @@ -664,6 +748,10 @@ public struct DebugAdapterCapabilities: Equatable, Sendable { self.supportsStepInTargetsRequest = supportsStepInTargetsRequest self.supportsGotoTargetsRequest = supportsGotoTargetsRequest self.supportsCompletionsRequest = supportsCompletionsRequest + self.supportsDisassembleRequest = supportsDisassembleRequest + self.supportsSteppingGranularity = supportsSteppingGranularity + self.supportsInstructionBreakpoints = supportsInstructionBreakpoints + self.supportsReadMemoryRequest = supportsReadMemoryRequest self.exceptionBreakpointFilters = exceptionBreakpointFilters } } @@ -681,6 +769,14 @@ public struct DebugStackFrame: Identifiable, Equatable, Sendable { public let line: Int public let column: Int public let isFiltered: Bool + /// Address of the frame's current instruction. A frame without debug info has no + /// file and `line` 0, so the disassembly view anchors on this instead. + public let instructionPointerReference: String? + + /// True when the frame has no file the editor could open. Adapters such as + /// lldb-dap still report a line for code without debug information (they serve + /// the disassembly as a synthetic source), so the line alone does not tell. + public var isSourceless: Bool { sourceURL == nil } public init( id: Int, @@ -688,7 +784,8 @@ public struct DebugStackFrame: Identifiable, Equatable, Sendable { sourceURL: URL?, line: Int, column: Int, - isFiltered: Bool = false + isFiltered: Bool = false, + instructionPointerReference: String? = nil ) { self.id = id self.name = name @@ -696,6 +793,7 @@ public struct DebugStackFrame: Identifiable, Equatable, Sendable { self.line = line self.column = column self.isFiltered = isFiltered + self.instructionPointerReference = instructionPointerReference } } @@ -706,6 +804,10 @@ public struct DebugScope: Identifiable, Equatable, Sendable { public let expensive: Bool public let namedVariables: Int public let indexedVariables: Int + /// DAP `presentationHint` (`arguments`, `locals`, `registers`), or nil. + public let presentationHint: String? + + public var isRegisters: Bool { presentationHint == "registers" } public init( id: Int, @@ -713,7 +815,8 @@ public struct DebugScope: Identifiable, Equatable, Sendable { variablesReference: Int, expensive: Bool, namedVariables: Int = 0, - indexedVariables: Int = 0 + indexedVariables: Int = 0, + presentationHint: String? = nil ) { self.id = id self.name = name @@ -721,6 +824,7 @@ public struct DebugScope: Identifiable, Equatable, Sendable { self.expensive = expensive self.namedVariables = max(0, namedVariables) self.indexedVariables = max(0, indexedVariables) + self.presentationHint = presentationHint } } @@ -788,6 +892,7 @@ public protocol DebugAdapterControllingSession: DebugAdapterSession { func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) + func setInstructionBreakpoints(_ breakpoints: [DebugInstructionBreakpoint]) func requestDataBreakpointInfo( name: String, variablesReference: Int?, @@ -802,6 +907,13 @@ public protocol DebugAdapterControllingSession: DebugAdapterSession { targetID: Int?, singleThread: Bool ) + /// Steps by `granularity`; adapters without `supportsSteppingGranularity` fall back + /// to their default step. + func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + granularity: DebugSteppingGranularity + ) func requestStepInTargets( frameID: Int, completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void @@ -818,6 +930,21 @@ public protocol DebugAdapterControllingSession: DebugAdapterSession { threadID: Int, completion: @escaping (Result) -> Void ) + /// Instructions around `memoryReference`; `instructionOffset` may be negative to ask + /// for instructions before it. Valid only while the thread stays stopped. + func requestDisassembly( + memoryReference: String, + instructionOffset: Int, + instructionCount: Int, + completion: @escaping (Result<[DebugDisassembledInstruction], Error>) -> Void + ) + /// Reads `count` bytes at `memoryReference` plus `offset`. Read-only. + func requestReadMemory( + memoryReference: String, + offset: Int, + count: Int, + completion: @escaping (Result) -> Void + ) func requestStackTrace(threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void) func requestScopes(frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void) func requestVariables(reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void) @@ -872,6 +999,30 @@ public extension DebugAdapterControllingSession { func setExceptionBreakpoints(_: [DebugExceptionBreakpoint]) {} func setFunctionBreakpoints(_: [DebugFunctionBreakpoint]) {} func setDataBreakpoints(_: [DebugDataBreakpoint]) {} + func setInstructionBreakpoints(_: [DebugInstructionBreakpoint]) {} + func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + granularity _: DebugSteppingGranularity + ) { + execute(command, threadID: threadID) + } + func requestDisassembly( + memoryReference _: String, + instructionOffset _: Int, + instructionCount _: Int, + completion: @escaping (Result<[DebugDisassembledInstruction], Error>) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("disassembly"))) + } + func requestReadMemory( + memoryReference _: String, + offset _: Int, + count _: Int, + completion: @escaping (Result) -> Void + ) { + completion(.failure(DebugAdapterCapabilityError.unsupported("memory reading"))) + } func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID _: Int?) { execute(command, threadID: threadID) } diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index 2d2d31ac41..621793cf74 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -120,7 +120,66 @@ public struct DebugCoreCapabilities: Decodable, Equatable, Sendable { public let supportsStepInTargetsRequest: Bool public let supportsGotoTargetsRequest: Bool public let supportsCompletionsRequest: Bool + public let supportsDisassembleRequest: Bool + public let supportsSteppingGranularity: Bool + public let supportsInstructionBreakpoints: Bool + public let supportsReadMemoryRequest: Bool public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] + + private enum CodingKeys: String, CodingKey { + case supportsConfigurationDone, supportsConditionalBreakpoints + case supportsHitConditionalBreakpoints, supportsLogPoints + case supportsFunctionBreakpoints, supportsDataBreakpoints + case supportsExceptionOptions, supportsExceptionFilterOptions + case supportsSetVariable, supportsCancelRequest + case supportsSingleThreadExecutionRequests, supportsRestartRequest + case supportsTerminateRequest, supportsStepBack, supportsExceptionInfoRequest + case supportsStepInTargetsRequest, supportsGotoTargetsRequest + case supportsCompletionsRequest, supportsDisassembleRequest + case supportsSteppingGranularity, supportsInstructionBreakpoints + case supportsReadMemoryRequest, exceptionBreakpointFilters + } + + /// The memory and instruction flags are newer than the first Core snapshot, so a + /// snapshot that omits them reads as "not supported" instead of failing to decode. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + supportsConfigurationDone = try container.decode(Bool.self, forKey: .supportsConfigurationDone) + supportsConditionalBreakpoints = try container.decode(Bool.self, forKey: .supportsConditionalBreakpoints) + supportsHitConditionalBreakpoints = try container.decode(Bool.self, forKey: .supportsHitConditionalBreakpoints) + supportsLogPoints = try container.decode(Bool.self, forKey: .supportsLogPoints) + supportsFunctionBreakpoints = try container.decode(Bool.self, forKey: .supportsFunctionBreakpoints) + supportsDataBreakpoints = try container.decode(Bool.self, forKey: .supportsDataBreakpoints) + supportsExceptionOptions = try container.decode(Bool.self, forKey: .supportsExceptionOptions) + supportsExceptionFilterOptions = try container.decode(Bool.self, forKey: .supportsExceptionFilterOptions) + supportsSetVariable = try container.decode(Bool.self, forKey: .supportsSetVariable) + supportsCancelRequest = try container.decode(Bool.self, forKey: .supportsCancelRequest) + supportsSingleThreadExecutionRequests = try container.decode( + Bool.self, forKey: .supportsSingleThreadExecutionRequests + ) + supportsRestartRequest = try container.decode(Bool.self, forKey: .supportsRestartRequest) + supportsTerminateRequest = try container.decode(Bool.self, forKey: .supportsTerminateRequest) + supportsStepBack = try container.decode(Bool.self, forKey: .supportsStepBack) + supportsExceptionInfoRequest = try container.decode(Bool.self, forKey: .supportsExceptionInfoRequest) + supportsStepInTargetsRequest = try container.decode(Bool.self, forKey: .supportsStepInTargetsRequest) + supportsGotoTargetsRequest = try container.decode(Bool.self, forKey: .supportsGotoTargetsRequest) + supportsCompletionsRequest = try container.decode(Bool.self, forKey: .supportsCompletionsRequest) + supportsDisassembleRequest = try container.decodeIfPresent( + Bool.self, forKey: .supportsDisassembleRequest + ) ?? false + supportsSteppingGranularity = try container.decodeIfPresent( + Bool.self, forKey: .supportsSteppingGranularity + ) ?? false + supportsInstructionBreakpoints = try container.decodeIfPresent( + Bool.self, forKey: .supportsInstructionBreakpoints + ) ?? false + supportsReadMemoryRequest = try container.decodeIfPresent( + Bool.self, forKey: .supportsReadMemoryRequest + ) ?? false + exceptionBreakpointFilters = try container.decode( + [DebugExceptionBreakpointFilter].self, forKey: .exceptionBreakpointFilters + ) + } } public struct DebugCoreOperationResult: Decodable, Equatable, Sendable { @@ -139,11 +198,52 @@ public struct DebugCoreOperationResult: Decodable, Equatable, Sendable { public let targets: [DebugCoreTarget]? public let changedClasses: [String]? public let items: [DebugCoreCompletionItem]? + /// `disassemble` result. + public let instructions: [DebugCoreInstruction]? + /// `readMemory` result: the address actually read, bytes past it that could not be + /// read, and the bytes as base64. + public let address: String? + public let unreadableBytes: Int? + public let dataBase64: String? private enum CodingKeys: String, CodingKey { case kind, command, threads, stackFrames, scopes, variables, variable, exceptionInfo case dataID = "dataId" case description, accessTypes, canPersist, targets, changedClasses, items + case instructions, address, unreadableBytes, dataBase64 + } +} + +public struct DebugCoreInstruction: Decodable, Equatable, Sendable { + public let address: String + public let instructionBytes: String? + public let instruction: String + public let symbol: String? + public let sourcePath: String? + public let line: Int? +} + +/// Arguments of a `disassemble` or `readMemory` inspection. `reference` is the opaque +/// address string; the host never parses it or does arithmetic on it. +public struct DebugCoreMemoryQuery: Equatable, Sendable { + public let reference: String + public let offset: Int? + public let instructionOffset: Int? + public let instructionCount: Int? + public let resolveSymbols: Bool? + + public init( + reference: String, + offset: Int? = nil, + instructionOffset: Int? = nil, + instructionCount: Int? = nil, + resolveSymbols: Bool? = nil + ) { + self.reference = reference + self.offset = offset + self.instructionOffset = instructionOffset + self.instructionCount = instructionCount + self.resolveSymbols = resolveSymbols } } @@ -172,6 +272,7 @@ public struct DebugCoreBreakpoint: Decodable, Equatable, Sendable { public let message: String? public let functionName: String? public let dataID: String? + public let instructionReference: String? public let sourcePath: String? public let requestedLine: Int? public let line: Int? @@ -180,6 +281,7 @@ public struct DebugCoreBreakpoint: Decodable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case id, verified, message, functionName case dataID = "dataId" + case instructionReference case sourcePath, requestedLine, line, column } } @@ -195,6 +297,8 @@ public struct DebugCoreStackFrame: Decodable, Equatable, Sendable { public let sourcePath: String? /// Set when the adapter serves the source itself, so `sourcePath` is not a file on disk. public let sourceReference: Int? + /// Address of the frame's current instruction; the anchor for a frame with no source. + public let instructionPointerReference: String? public let line: Int public let column: Int public let isFiltered: Bool @@ -204,6 +308,7 @@ public struct DebugCoreStackFrame: Decodable, Equatable, Sendable { name: String, sourcePath: String?, sourceReference: Int? = nil, + instructionPointerReference: String? = nil, line: Int, column: Int, isFiltered: Bool = false @@ -212,13 +317,15 @@ public struct DebugCoreStackFrame: Decodable, Equatable, Sendable { self.name = name self.sourcePath = sourcePath self.sourceReference = sourceReference + self.instructionPointerReference = instructionPointerReference self.line = line self.column = column self.isFiltered = isFiltered } private enum CodingKeys: String, CodingKey { - case id, name, sourcePath, sourceReference, line, column, isFiltered + case id, name, sourcePath, sourceReference, instructionPointerReference + case line, column, isFiltered } public init(from decoder: any Decoder) throws { @@ -227,6 +334,9 @@ public struct DebugCoreStackFrame: Decodable, Equatable, Sendable { name = try container.decode(String.self, forKey: .name) sourcePath = try container.decodeIfPresent(String.self, forKey: .sourcePath) sourceReference = try container.decodeIfPresent(Int.self, forKey: .sourceReference) + instructionPointerReference = try container.decodeIfPresent( + String.self, forKey: .instructionPointerReference + ) line = try container.decode(Int.self, forKey: .line) column = try container.decode(Int.self, forKey: .column) isFiltered = try container.decodeIfPresent(Bool.self, forKey: .isFiltered) ?? false @@ -239,9 +349,11 @@ public struct DebugCoreScope: Decodable, Equatable, Sendable { public let expensive: Bool public let namedVariables: Int public let indexedVariables: Int + public let presentationHint: String? private enum CodingKeys: String, CodingKey { case name, variablesReference, expensive, namedVariables, indexedVariables + case presentationHint } public init(from decoder: any Decoder) throws { @@ -249,6 +361,7 @@ public struct DebugCoreScope: Decodable, Equatable, Sendable { name = try container.decode(String.self, forKey: .name) variablesReference = try container.decode(Int.self, forKey: .variablesReference) expensive = try container.decode(Bool.self, forKey: .expensive) + presentationHint = try container.decodeIfPresent(String.self, forKey: .presentationHint) namedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .namedVariables) ?? 0) indexedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .indexedVariables) ?? 0) } @@ -359,6 +472,10 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { sessionID: String, breakpoints: [DebugFunctionBreakpoint] ) throws -> DebugCoreUpdate + func setDebugInstructionBreakpoints( + sessionID: String, + breakpoints: [DebugInstructionBreakpoint] + ) throws -> DebugCoreUpdate func debugDataBreakpointInfo( sessionID: String, operationID: String, @@ -388,7 +505,8 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { command: DebugExecutionCommand, threadID: Int?, targetID: Int?, - singleThread: Bool + singleThread: Bool, + granularity: DebugSteppingGranularity? ) throws -> DebugCoreUpdate func inspectDebugSession( sessionID: String, @@ -404,7 +522,8 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { context: String?, sourcePath: String?, line: Int?, - column: Int? + column: Int?, + memory: DebugCoreMemoryQuery? ) throws -> DebugCoreUpdate func receiveDebugData(sessionID: String, data: Data) throws -> DebugCoreUpdate func completeDebugRunInTerminalRequest( diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift new file mode 100644 index 0000000000..b6230ad430 --- /dev/null +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift @@ -0,0 +1,581 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// Text-level helpers for adapter-formatted addresses. The host never turns an address +/// into an integer or adds to it: it only decides whether two spellings name the same +/// address, so a 64-bit pointer can never be truncated or wrapped here. +public enum DebugAddressText { + /// Lowercase, no `0x`, no leading zeros, so `0x0000000100003f50` equals `0x100003f50`. + public static func canonical(_ address: String) -> String { + var text = address.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if text.hasPrefix("0x") { text.removeFirst(2) } + let digits = text.drop { $0 == "0" } + return digits.isEmpty ? "0" : String(digits) + } + + public static func areEqual(_ lhs: String, _ rhs: String) -> Bool { + lhs == rhs || canonical(lhs) == canonical(rhs) + } + + /// The first `0x…` literal in `text`, such as the pointer in a register value + /// `0x000000016fdff8a0 -> ...`. + public static func firstHexLiteral(in text: String) -> String? { + guard let range = text.range(of: "0[xX][0-9a-fA-F]+", options: .regularExpression) else { + return nil + } + return String(text[range]) + } + + /// True when the whole trimmed text is one `0x…` literal. + public static func isHexLiteral(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.range(of: "^0[xX][0-9a-fA-F]+$", options: .regularExpression) != nil + } +} + +/// An instruction breakpoint set from the disassembly view. It lives only as long as the +/// session it was set in: the same code sits at another address in the next run. +public struct GenericDebugInstructionBreakpoint: Identifiable, Equatable, Sendable { + public let address: String + public var enabled: Bool + public var verified: Bool + public var message: String? + + public var id: String { DebugAddressText.canonical(address) } + + public init(address: String, enabled: Bool = true, verified: Bool = false, message: String? = nil) { + self.address = address + self.enabled = enabled + self.verified = verified + self.message = message + } +} + +/// One page of debuggee bytes. Lines are labelled with their byte offset from `reference` +/// so the host does not do address arithmetic. +public struct GenericDebugMemoryDump: Equatable, Sendable { + public static let pageSize = 256 + /// Bytes read before the address that was asked for, so the address is not the first thing + /// on the page: the user sees what precedes it and the highlighted bytes are not at the edge. + public static let contextBytes = 64 + + /// What the user asked for; later pages are requested relative to it. + public let reference: String + /// Bytes from `reference` to the first byte of this page. + public let pageOffset: Int + /// Where the adapter says the first byte is. + public let address: String + /// Bytes after the readable ones that the adapter could not read. + public let unreadableBytes: Int + public let data: Data + + public init(reference: String, pageOffset: Int, address: String, unreadableBytes: Int, data: Data) { + self.reference = reference + self.pageOffset = pageOffset + self.address = address + self.unreadableBytes = unreadableBytes + self.data = data + } + + /// Index in `data` of the byte at `reference`, when this page holds it. + public var targetIndex: Int? { + let index = -pageOffset + return index >= 0 && index < data.count ? index : nil + } +} + +/// State behind the disassembly and memory views: which instructions are listed around +/// the selected frame's instruction pointer, the instruction breakpoints the user set, and +/// the memory page being read. It holds no UI text; the view localizes every message. +@MainActor +public final class GenericDebugDisassemblyModel: ObservableObject { + /// What the model needs from the feature model that owns it. + public struct Environment { + public var session: () -> (any DebugAdapterControllingSession)? + public var capabilities: () -> DebugAdapterCapabilities + public var selectedFrameID: () -> Int? + public var breakpointsChanged: (DebugSessionID, [DebugInstructionBreakpoint]) -> Void + + public init( + session: @escaping () -> (any DebugAdapterControllingSession)?, + capabilities: @escaping () -> DebugAdapterCapabilities, + selectedFrameID: @escaping () -> Int?, + breakpointsChanged: @escaping (DebugSessionID, [DebugInstructionBreakpoint]) -> Void + ) { + self.session = session + self.capabilities = capabilities + self.selectedFrameID = selectedFrameID + self.breakpointsChanged = breakpointsChanged + } + } + + public enum Direction { case before, after } + + public enum MemoryFailure: Equatable { + /// The typed text was neither a `0x…` address nor an expression that evaluates to one. + case unresolvedAddress + /// The adapter's own diagnostic, shown as it came. + case adapter(String) + } + + /// Instructions requested ahead of and around the instruction pointer. + static let instructionsBefore = 24 + static let initialInstructionCount = 96 + static let pageInstructionCount = 64 + /// Instructions left below the current one before the listing is extended while stepping. + static let edgeInstructionCount = 8 + + @Published public private(set) var isPresented = false + @Published public private(set) var rows: [DebugDisassembledInstruction] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var loadFailure: String? + @Published public private(set) var breakpoints: [GenericDebugInstructionBreakpoint] = [] + @Published public private(set) var memory: GenericDebugMemoryDump? + @Published public private(set) var isLoadingMemory = false + @Published public private(set) var memoryFailure: MemoryFailure? + /// Indexes in `memory.data` whose byte differs from the same page read before the last step. + @Published public private(set) var memoryChangedIndices: Set = [] + /// Bumped when the user asked for a page, so the view brings the highlighted bytes into view. + /// A refresh after a step leaves it alone: it must not move what the user is reading. + @Published public private(set) var memoryRevealToken = 0 + + /// Address of the selected frame's current instruction, as the adapter reported it. + @Published public private(set) var currentAddress: String? + + public var environment: Environment? + + private var disassemblyGeneration = 0 + private var memoryGeneration = 0 + private var presentedAutomatically = false + private var activeSessionID: DebugSessionID? + private var breakpointsBySession: [DebugSessionID: [GenericDebugInstructionBreakpoint]] = [:] + + public init() {} + + // MARK: - Capabilities + + public var canDisassemble: Bool { environment?.capabilities().supportsDisassembleRequest ?? false } + public var canReadMemory: Bool { environment?.capabilities().supportsReadMemoryRequest ?? false } + public var canSetInstructionBreakpoints: Bool { + environment?.capabilities().supportsInstructionBreakpoints ?? false + } + + /// Stepping moves one instruction while the disassembly is on screen, as long as the + /// adapter can step by instruction. Otherwise a step would skip a whole source line. + public var stepsByInstruction: Bool { + isPresented && canStepByInstruction + } + + /// Whether the adapter honours `granularity: instruction` on step requests. + public var canStepByInstruction: Bool { + environment?.capabilities().supportsSteppingGranularity ?? false + } + + // MARK: - Presentation + + public func present() { + guard !isPresented else { return } + isPresented = true + presentedAutomatically = false + reload() + } + + public func dismiss() { + guard isPresented else { return } + isPresented = false + presentedAutomatically = false + disassemblyGeneration &+= 1 + isLoading = false + } + + public func toggle() { + isPresented ? dismiss() : present() + } + + public func isCurrent(_ instruction: DebugDisassembledInstruction) -> Bool { + guard let currentAddress else { return false } + return DebugAddressText.areEqual(instruction.address, currentAddress) + } + + // MARK: - Following the stack + + /// The selected frame changed. A frame without source has nothing else to show, so + /// the view opens by itself and closes again once a frame with source is selected. + /// + /// `preservingView` is a step landing in the same thread. The listing then stays put and only + /// the current-instruction marker moves, unless the new instruction is outside the listed + /// range. The memory page stays on screen and is read again quietly, so bytes the step + /// changed are marked instead of the whole page blinking away. + public func frameSelected(_ frame: DebugStackFrame, preservingView: Bool = false) { + disassemblyGeneration &+= 1 + memoryGeneration &+= 1 + isLoading = false + currentAddress = frame.instructionPointerReference + if preservingView { + refreshMemoryInPlace() + } else { + clearMemory() + } + if frame.isSourceless, currentAddress != nil, canDisassemble { + if !isPresented { + isPresented = true + presentedAutomatically = true + } + } else if presentedAutomatically { + isPresented = false + presentedAutomatically = false + } + if preservingView, isPresented, currentAddress != nil, followInstructionPointer() { + return + } + reload() + } + + /// Keeps the existing listing when it already holds the current instruction. Returns false + /// when the listing has to be read again. + private func followInstructionPointer() -> Bool { + guard let index = rows.firstIndex(where: { isCurrent($0) }) else { return false } + // Stepping towards the end of the listing: extend it before the marker runs off. + if rows.count - index <= Self.edgeInstructionCount { loadMore(.after) } + return true + } + + /// The debuggee resumed or the stack was thrown away, so every address is stale. + public func invalidate() { + suspendRequests() + currentAddress = nil + rows = [] + isLoading = false + loadFailure = nil + memory = nil + memoryChangedIndices = [] + memoryFailure = nil + isLoadingMemory = false + } + + /// Retires callbacks before the session cancels operations synchronously. A step keeps + /// the displayed bytes and instructions, but no response may mutate the previous stop. + public func suspendRequests() { + disassemblyGeneration &+= 1 + memoryGeneration &+= 1 + isLoading = false + isLoadingMemory = false + } + + // MARK: - Sessions + + /// The visible session changed; breakpoints belong to one session's debuggee. + public func activate(sessionID: DebugSessionID?) { + guard activeSessionID != sessionID else { return } + activeSessionID = sessionID + breakpoints = sessionID.flatMap { breakpointsBySession[$0] } ?? [] + invalidate() + } + + public func forget(sessionID: DebugSessionID) { + breakpointsBySession[sessionID] = nil + if activeSessionID == sessionID { + breakpoints = [] + } + } + + // MARK: - Disassembly + + public func reload() { + guard isPresented, + let anchor = currentAddress, + canDisassemble, + let session = environment?.session() else { + rows = [] + isLoading = false + return + } + disassemblyGeneration &+= 1 + let token = disassemblyGeneration + isLoading = true + loadFailure = nil + request( + session: session, + generation: token, + reference: anchor, + offset: -Self.instructionsBefore, + count: Self.initialInstructionCount, + retriesForward: true + ) { [weak self] instructions in + guard let self, self.disassemblyGeneration == token else { return } + self.rows = instructions + self.isLoading = false + } onFailure: { [weak self] message in + guard let self, self.disassemblyGeneration == token else { return } + self.rows = [] + self.loadFailure = message + self.isLoading = false + } + } + + /// Lists another page above the first or below the last instruction. + public func loadMore(_ direction: Direction) { + guard !isLoading, + let session = environment?.session(), + let edge = direction == .before ? rows.first : rows.last else { return } + disassemblyGeneration &+= 1 + let token = disassemblyGeneration + isLoading = true + loadFailure = nil + request( + session: session, + generation: token, + reference: edge.address, + offset: direction == .before ? -Self.pageInstructionCount : 1, + count: Self.pageInstructionCount, + retriesForward: false + ) { [weak self] instructions in + guard let self, self.disassemblyGeneration == token else { return } + let known = Set(self.rows.map { DebugAddressText.canonical($0.address) }) + let fresh = instructions.filter { !known.contains(DebugAddressText.canonical($0.address)) } + self.rows = direction == .before ? fresh + self.rows : self.rows + fresh + self.isLoading = false + } onFailure: { [weak self] message in + guard let self, self.disassemblyGeneration == token else { return } + self.loadFailure = message + self.isLoading = false + } + } + + /// An adapter may refuse a negative `instructionOffset`; the first window then starts + /// at the instruction pointer instead of failing the whole view. + private func request( + session: any DebugAdapterControllingSession, + generation: Int, + reference: String, + offset: Int, + count: Int, + retriesForward: Bool, + onSuccess: @escaping ([DebugDisassembledInstruction]) -> Void, + onFailure: @escaping (String) -> Void + ) { + session.requestDisassembly( + memoryReference: reference, + instructionOffset: offset, + instructionCount: count + ) { [weak self] result in + guard let self, self.disassemblyGeneration == generation else { return } + switch result { + case .success(let instructions): + onSuccess(instructions) + case .failure(let error): + if retriesForward, offset < 0 { + self.request( + session: session, + generation: generation, + reference: reference, + offset: 0, + count: count, + retriesForward: false, + onSuccess: onSuccess, + onFailure: onFailure + ) + } else { + onFailure(error.localizedDescription) + } + } + } + } + + // MARK: - Instruction breakpoints + + public func breakpoint(at address: String) -> GenericDebugInstructionBreakpoint? { + breakpoints.first { DebugAddressText.areEqual($0.address, address) } + } + + /// Sets or clears the breakpoint at an instruction address from the disassembly. + public func toggleBreakpoint(at address: String) { + guard canSetInstructionBreakpoints, activeSessionID != nil else { return } + if let index = breakpoints.firstIndex(where: { DebugAddressText.areEqual($0.address, address) }) { + breakpoints.remove(at: index) + } else { + breakpoints.append(GenericDebugInstructionBreakpoint(address: address)) + } + publishBreakpoints() + } + + public func removeBreakpoint(_ breakpoint: GenericDebugInstructionBreakpoint) { + breakpoints.removeAll { $0.id == breakpoint.id } + publishBreakpoints() + } + + /// The adapter reported whether an instruction breakpoint took effect. + public func resolveBreakpoint(reference: String, verified: Bool, message: String?) { + guard let index = breakpoints.firstIndex(where: { DebugAddressText.areEqual($0.address, reference) }) else { + return + } + breakpoints[index].verified = verified + breakpoints[index].message = message + if let activeSessionID { + breakpointsBySession[activeSessionID] = breakpoints + } + } + + private func publishBreakpoints() { + guard let activeSessionID else { return } + breakpointsBySession[activeSessionID] = breakpoints + environment?.breakpointsChanged( + activeSessionID, + breakpoints.map { + DebugInstructionBreakpoint(instructionReference: $0.address, enabled: $0.enabled) + } + ) + } + + // MARK: - Memory + + /// Reads memory at a `0x…` address, or at what an expression such as a register name + /// evaluates to in the selected frame. + public func readMemory(from input: String) { + let text = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + if DebugAddressText.isHexLiteral(text) { + readMemory(around: text) + return + } + guard let session = environment?.session() else { return } + memoryGeneration &+= 1 + let token = memoryGeneration + isLoadingMemory = true + memoryFailure = nil + session.evaluate(text, frameID: environment?.selectedFrameID()) { [weak self] result in + guard let self, self.memoryGeneration == token else { return } + switch result { + case .success(let variable): + if let address = DebugAddressText.firstHexLiteral(in: variable.value) { + self.readMemory(around: address) + } else { + self.isLoadingMemory = false + self.memoryFailure = .unresolvedAddress + } + case .failure(let error): + self.isLoadingMemory = false + self.memoryFailure = .adapter(error.localizedDescription) + } + } + } + + /// Reads a page that starts a little before `reference`, so what precedes the address is + /// visible and the address itself is not on the first row. An adapter that cannot read + /// below the address (the start of a mapping, or no negative offsets) gets the page that + /// starts at the address instead. + private func readMemory(around reference: String) { + loadMemory( + reference: reference, + pageOffset: -GenericDebugMemoryDump.contextBytes, + fallbackPageOffset: 0 + ) + } + + /// Reads the page that starts `pageOffset` bytes from `reference`. + public func readMemory(reference: String, pageOffset: Int) { + loadMemory(reference: reference, pageOffset: pageOffset, fallbackPageOffset: nil) + } + + private func loadMemory(reference: String, pageOffset: Int, fallbackPageOffset: Int?) { + guard canReadMemory, let session = environment?.session() else { return } + memoryGeneration &+= 1 + let token = memoryGeneration + isLoadingMemory = true + memoryFailure = nil + session.requestReadMemory( + memoryReference: reference, + offset: pageOffset, + count: GenericDebugMemoryDump.pageSize + ) { [weak self] result in + guard let self, self.memoryGeneration == token else { return } + var failure: String? + switch result { + case .success(let read) where read.data.isEmpty && fallbackPageOffset != nil: + failure = "" + case .success(let read): + self.isLoadingMemory = false + self.memoryChangedIndices = [] + self.memory = GenericDebugMemoryDump( + reference: reference, + pageOffset: pageOffset, + address: read.address, + unreadableBytes: read.unreadableBytes, + data: read.data + ) + self.memoryRevealToken &+= 1 + case .failure(let error): + failure = error.localizedDescription + } + guard let failure else { return } + if let fallbackPageOffset { + self.loadMemory(reference: reference, pageOffset: fallbackPageOffset, fallbackPageOffset: nil) + } else { + self.isLoadingMemory = false + self.memoryFailure = .adapter(failure) + } + } + } + + public func showMemoryPage(_ direction: Direction) { + guard let memory else { return } + let step = GenericDebugMemoryDump.pageSize + let next = memory.pageOffset + (direction == .after ? step : -step) + readMemory(reference: memory.reference, pageOffset: next) + } + + /// Reads the page on screen again after a step, with no spinner and no scrolling. The bytes + /// the step changed are recorded so the view can mark them. + private func refreshMemoryInPlace() { + guard let current = memory, canReadMemory, let session = environment?.session() else { + clearMemory() + return + } + memoryFailure = nil + isLoadingMemory = false + let token = memoryGeneration + session.requestReadMemory( + memoryReference: current.reference, + offset: current.pageOffset, + count: GenericDebugMemoryDump.pageSize + ) { [weak self] result in + guard let self, self.memoryGeneration == token else { return } + switch result { + case .success(let read): + let fresh = GenericDebugMemoryDump( + reference: current.reference, + pageOffset: current.pageOffset, + address: read.address, + unreadableBytes: read.unreadableBytes, + data: read.data + ) + let changed = Self.changedIndices(from: current.data, to: fresh.data) + if self.memoryChangedIndices != changed { self.memoryChangedIndices = changed } + if self.memory != fresh { self.memory = fresh } + case .failure(let error): + // Showing the old bytes as if they were current would mislead more than an error. + self.memory = nil + self.memoryChangedIndices = [] + self.memoryFailure = .adapter(error.localizedDescription) + } + } + } + + static func changedIndices(from old: Data, to new: Data) -> Set { + var changed: Set = [] + for index in 0 ..< min(old.count, new.count) + where old[old.startIndex + index] != new[new.startIndex + index] { + changed.insert(index) + } + return changed + } + + public func clearMemory() { + memoryGeneration &+= 1 + memory = nil + memoryChangedIndices = [] + memoryFailure = nil + isLoadingMemory = false + } +} diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 9192cf7f5d..1247fa990c 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -121,6 +121,20 @@ private struct GenericDebugVariablePageItemFingerprint: Hashable, Sendable { let variablesReference: Int } +/// The variable tree of the previous stop, kept on screen while the next stop's tree is read. +/// +/// A step must not blank the Variables panel, so the tree being replaced is frozen here and the +/// panel keeps reading it until the new one (including every re-expanded node) has fully arrived. +/// Expansion is carried over by variable *name path* (`["self", "items"]`) because DAP ids +/// (`variablesReference` and the ids derived from it) are only valid for one stop. +private struct GenericDebugVariableSnapshot { + let variables: [DebugVariable] + let children: [String: [DebugVariable]] + let expanded: Set + let pageStates: [String: GenericDebugVariablePageState] + let expandedPaths: Set<[String]> +} + private struct GenericDebugStartRequest: Equatable, Sendable { let fileURL: URL let rootURL: URL @@ -307,7 +321,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } @Published public private(set) var providerID: String? - @Published public private(set) var activeSessionID: DebugSessionID? + @Published public private(set) var activeSessionID: DebugSessionID? { + didSet { + // Instruction addresses belong to one debuggee, so the view follows the session. + if oldValue != activeSessionID { disassembly.activate(sessionID: activeSessionID) } + } + } @Published public private(set) var sessionSummaries: [DebugSessionSummary] = [] @Published public private(set) var targetTitle: String? @Published public private(set) var state: DebugAdapterState = .idle { @@ -359,6 +378,30 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu /// Prevents overlapping execution requests while the adapter is still /// acknowledging the previous step/continue operation. @Published public private(set) var isExecutionRequestPending = false + /// True while the variable tree of the previous stop is being replaced after a step. The old + /// tree stays visible; the JSON view and expansion gestures wait for the swap. + @Published public private(set) var isRefreshingVariables = false + + /// A step is in flight, so the previous stop's panels stay on screen and are replaced in + /// place by the next stop instead of being blanked and rebuilt. + private var retainsInspectionForStep = false + /// The previous stop's tree while the live tree below is being rebuilt behind it. + private var staleVariables: GenericDebugVariableSnapshot? { + didSet { + let refreshing = staleVariables != nil + if isRefreshingVariables != refreshing { isRefreshingVariables = refreshing } + } + } + /// Name path of every variable of the tree being rebuilt, to match it against the + /// expansion of the tree it replaces. + private var refreshedVariablePaths: [String: [String]] = [:] + /// The automatic-variable results still belong to the previous stop and are updated in place. + private var automaticResultsArePreviousStop = false + /// The debuggee is running after a step and the panels still show the stop it left. The + /// view may dim them; they must not be edited or expanded because the adapter cannot answer. + public var isInspectionStale: Bool { canRetainInspection && state == .running } + /// Session switches and restarts empty the call stack, which also ends any pending retention. + private var canRetainInspection: Bool { retainsInspectionForStep && !stackFrames.isEmpty } /// Delivers the selected stopped frame to the host editor for source /// navigation. The Debug module does not own editor presentation. @@ -401,6 +444,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private var sessionSnapshots: [DebugSessionID: GenericDebugSessionSnapshot] = [:] /// Suggestion popup state for the Debug Console prompt. public let consolePrompt = DebugConsolePromptModel() + /// Disassembly, instruction breakpoints and memory for the selected frame. + public let disassembly = GenericDebugDisassemblyModel() private var consoleCompletionGeneration = 0 private var consoleHistoryBySession: [DebugSessionID: [String]] = [:] private var consoleHistoryCursorBySession: [DebugSessionID: Int] = [:] @@ -432,6 +477,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu consolePrompt.provider = { [weak self] text, caret, completion in self?.requestConsoleCompletions(text: text, caret: caret, completion: completion) } + disassembly.environment = GenericDebugDisassemblyModel.Environment( + session: { [weak self] in self?.activeSession }, + capabilities: { [weak self] in self?.capabilities ?? .unknown }, + selectedFrameID: { [weak self] in self?.selectedFrameID }, + breakpointsChanged: { [weak self] sessionID, breakpoints in + self?.sessions.setInstructionBreakpoints(breakpoints, sessionID: sessionID) + } + ) sessionSummaries = sessions.sessionSummaries sessions.onSessionStateChange = { [weak self] sessionID, providerID, state in guard let self else { return } @@ -449,9 +502,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self.isExecutionRequestPending = false } if state == .running { - self.clearStoppedInspection() + self.suspendOrClearStoppedInspection() } if state == .terminated || state == .failed { + self.clearStoppedInspection() self.outputPresentation.flush() } self.saveActiveSessionSnapshot() @@ -532,7 +586,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var presentedVariables: [DebugVariable] { var knownNames = Set() var result: [DebugVariable] = [] - for variable in variables + automaticVariables { + for variable in displayedRootVariables + automaticVariables { guard knownNames.insert(variable.name).inserted else { continue } result.append(variable) } @@ -669,6 +723,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu guard forgottenSessionIDs.insert(sessionID).inserted else { return } javaUpdateTargets[sessionID] = nil sessionSnapshots[sessionID] = nil + disassembly.forget(sessionID: sessionID) onSessionStopped?(sessionID) } @@ -1251,7 +1306,15 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu command == .stepBack || command == .goto { isExecutionRequestPending = true } - session.execute(command, threadID: selectedThreadID) + // Only a step lands on a nearby stop worth updating in place; continue, pause, restart + // and stop can end anywhere, so they keep the full reset. + retainsInspectionForStep = command == .next || command == .stepIn + || command == .stepOut || command == .stepBack + if disassembly.stepsByInstruction, command == .next || command == .stepIn { + session.execute(command, threadID: selectedThreadID, granularity: .instruction) + } else { + session.execute(command, threadID: selectedThreadID) + } } public func updateJavaSteppingFilters(_ filters: DebugSteppingFilters) { @@ -1296,6 +1359,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu || (command == .pause && state == .running), let session = activeSession else { return } isExecutionRequestPending = true + retainsInspectionForStep = false session.execute( command, threadID: thread.id, @@ -1322,6 +1386,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func smartStepInto(_ target: DebugStepInTarget) { guard let selectedThreadID, let session = activeSession else { return } + retainsInspectionForStep = true session.execute(.stepIn, threadID: selectedThreadID, targetID: target.id) } @@ -1349,6 +1414,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public func runToCursor(_ target: DebugGotoTarget) { guard let selectedThreadID, let session = activeSession else { return } + retainsInspectionForStep = false session.execute(.goto, threadID: selectedThreadID, targetID: target.id) } @@ -1372,6 +1438,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedThreadID = thread.id selectedFrameID = nil selectedFrame = nil + disassembly.invalidate() stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] @@ -1405,15 +1472,28 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectFrame(frame, generation: generation) } - private func selectFrame(_ frame: DebugStackFrame, generation: Int) { + /// `preserving` is a step landing: scopes, variables, watches and automatic values of the previous + /// stop stay visible and are swapped for the new stop's values as they arrive. + private func selectFrame(_ frame: DebugStackFrame, generation: Int, preserving: Bool = false) { + let previousScopeName = preserving ? scopes.first(where: { $0.id == selectedScopeID })?.name : nil selectedFrameID = frame.id selectedFrame = frame - scopes = [] - selectedScopeID = nil - resetVariableTree() - resetAutomaticVariables() - invalidateWatchResults() + if preserving { + if onAutomaticVariableInspectionRequest == nil { + resetAutomaticVariables() + } else { + automaticExpressionFrameID = nil + automaticResultsArePreviousStop = true + } + } else { + scopes = [] + selectedScopeID = nil + resetVariableTree() + resetAutomaticVariables() + invalidateWatchResults() + } publishStoppedLocation(frame) + disassembly.frameSelected(frame, preservingView: preserving) onAutomaticVariableInspectionRequest?(frame) refreshWatches() guard let session = activeSession else { return } @@ -1424,12 +1504,15 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu switch result { case .success(let scopes): self.scopes = scopes - if let scope = scopes.first(where: { !$0.expensive }) ?? scopes.first { - self.selectScope(scope, frameID: frame.id, generation: generation) + let kept = previousScopeName.flatMap { name in scopes.first(where: { $0.name == name }) } + if let scope = kept ?? scopes.first(where: { !$0.expensive }) ?? scopes.first { + self.selectScope(scope, frameID: frame.id, generation: generation, preserving: preserving) } else { self.resetVariableTree() } - case .failure(let error): self.record(error) + case .failure(let error): + self.record(error) + if preserving { self.abandonPreservedInspection() } } } } @@ -1440,7 +1523,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectScope(scope, frameID: frameID, generation: inspectionGeneration) } - private func selectScope(_ scope: DebugScope, frameID: Int, generation: Int) { + private func selectScope(_ scope: DebugScope, frameID: Int, generation: Int, preserving: Bool = false) { guard selectedFrameID == frameID, inspectionGeneration == generation else { return } selectedScopeID = scope.id loadVariables( @@ -1448,7 +1531,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu namedVariables: scope.namedVariables, indexedVariables: scope.indexedVariables, frameID: frameID, - generation: generation + generation: generation, + preserving: preserving ) } @@ -1482,8 +1566,15 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu automaticExpressionFrameID = frameID automaticExpressionOrder = orderedExpressions - automaticExpressionResults = [:] - automaticVariables = [] + if automaticResultsArePreviousStop { + // A step keeps the values of the line it left until each expression is evaluated again. + automaticResultsArePreviousStop = false + automaticExpressionResults = automaticExpressionResults.filter { orderedExpressions.contains($0.key) } + publishAutomaticVariables(orderedExpressions) + } else { + automaticExpressionResults = [:] + automaticVariables = [] + } guard !orderedExpressions.isEmpty else { return } let generation = inspectionGeneration @@ -1548,6 +1639,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } else { // Source-driven evaluation is speculative. Invalid // candidates should not flood the user's Debug Console. + self.automaticExpressionResults.removeValue(forKey: expression) self.publishAutomaticVariables(orderedExpressions) } } @@ -1565,9 +1657,19 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu namedVariables: Int, indexedVariables: Int, frameID: Int?, - generation: Int + generation: Int, + preserving: Bool = false ) { - resetVariableTree() + if preserving { + // The tree being replaced keeps being displayed (and its expansion is remembered by + // name path) until the new one has fully arrived behind it. + let carried = staleVariables ?? captureVariableTree() + clearLiveVariableTree() + refreshedVariablePaths = [:] + staleVariables = carried + } else { + resetVariableTree() + } variablePageStates[Self.rootVariablePageID] = makeVariablePageState( reference: reference, namedVariables: namedVariables, @@ -1579,11 +1681,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu generation: generation, expandsParent: false ) + commitVariableRefreshIfSettled() } /// Reads the selected paused scope without changing tree expansion or executing target code. public func variableJSONSnapshot() async throws -> String { - guard state == .paused, let session = activeSession else { throw CancellationError() } + guard state == .paused, staleVariables == nil, let session = activeSession else { + throw CancellationError() + } let generation = inspectionGeneration let sessionID = activeSessionID let frameID = selectedFrameID @@ -1600,7 +1705,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func toggleVariableExpansion(_ variable: DebugVariable) { - guard variable.isExpandable else { return } + guard variable.isExpandable, !isVariableTreeBusy else { return } if expandedVariableIDs.contains(variable.id) { expandedVariableIDs.remove(variable.id) return @@ -1625,6 +1730,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func loadMoreVariables(parentVariableID: String?) { + guard !isVariableTreeBusy else { return } requestVariablePage( parentVariableID: parentVariableID, frameID: selectedFrameID, @@ -1634,23 +1740,27 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func isVariablePageLoading(parentVariableID: String?) -> Bool { - loadingVariablePageIDs.contains(variablePageID(parentVariableID)) + staleVariables == nil && loadingVariablePageIDs.contains(variablePageID(parentVariableID)) } public func children(of variable: DebugVariable) -> [DebugVariable] { - variableChildren[variable.id] ?? [] + displayedChildren(of: variable.id) ?? [] } public func isVariableExpanded(_ variable: DebugVariable) -> Bool { - expandedVariableIDs.contains(variable.id) + isDisplayedExpanded(variable.id) } public func isVariableLoading(_ variable: DebugVariable) -> Bool { - loadingVariableIDs.contains(variable.id) + staleVariables == nil && loadingVariableIDs.contains(variable.id) } + /// The tree on screen belongs to a stop the debuggee has already left, or is being replaced. + private var isVariableTreeBusy: Bool { staleVariables != nil || isInspectionStale } + public func setVariable(_ variable: DebugVariable, value: String) { guard state == .paused, + !isVariableTreeBusy, capabilities.supportsSetVariable, let containerReference = variable.containerReference, let session = activeSession else { return } @@ -1898,6 +2008,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrameID = nil stoppedFrame = nil selectedFrame = nil + disassembly.invalidate() threads = [] stoppedThreadIDs = [] stackFrames = [] @@ -1910,10 +2021,27 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } private func clearStoppedInspection() { + retainsInspectionForStep = false invalidateInspectionRequests() resetInspectionState() } + /// A step resumed the debuggee: the panels keep showing the stop it left (the view dims them) + /// until the next stop replaces their contents in place. Anything else clears them. + private func suspendOrClearStoppedInspection() { + guard canRetainInspection else { + clearStoppedInspection() + return + } + // Freeze at resume, not when scopes arrive: old references must stay read-only + // throughout the threads -> stack -> scopes refresh, including its paused interval. + if staleVariables == nil { staleVariables = captureVariableTree() } + // Answers still in flight belong to the stop being left and must not land in the new one. + invalidateInspectionRequests() + if !loadingVariablePageIDs.isEmpty { loadingVariablePageIDs = [] } + if !loadingVariableIDs.isEmpty { loadingVariableIDs = [] } + } + private func saveActiveSessionSnapshot() { guard let activeSessionID, let providerID else { return } sessionSnapshots[activeSessionID] = GenericDebugSessionSnapshot( @@ -2085,6 +2213,10 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .stopped(let reason, let threadID, let description): outputPresentation.flush() isExecutionRequestPending = false + // A step lands a few instructions or lines away, so the panels keep the previous + // stop and are replaced in place as each answer arrives instead of blanking first. + let preserving = canRetainInspection + if preserving, staleVariables == nil { staleVariables = captureVariableTree() } let generation = beginInspectionTransition() if let threadID { stoppedThreadIDs.insert(threadID) @@ -2093,23 +2225,29 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } stoppedReason = description ?? reason exceptionInfo = nil - selectedThreadID = threadID - selectedFrameID = nil - selectedFrame = nil - threads = [] - stackFrames = [] - areFilteredStackFramesExpanded = false - scopes = [] - resetVariableTree() - resetAutomaticVariables() - invalidateWatchResults() + if preserving { + selectedThreadID = threadID ?? selectedThreadID + } else { + selectedThreadID = threadID + selectedFrameID = nil + selectedFrame = nil + disassembly.invalidate() + threads = [] + stackFrames = [] + areFilteredStackFramesExpanded = false + scopes = [] + resetVariableTree() + resetAutomaticVariables() + invalidateWatchResults() + } if reason == "exception", let threadID { loadExceptionInfo(threadID: threadID, generation: generation) } loadStoppedContext( threadID: threadID, generation: generation, - shouldLoadExceptionInfo: reason == "exception" && threadID == nil + shouldLoadExceptionInfo: reason == "exception" && threadID == nil, + preserving: preserving ) case .continued(let threadID): isExecutionRequestPending = false @@ -2118,13 +2256,21 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } else { stoppedThreadIDs = [] } - clearStoppedInspection() + suspendOrClearStoppedInspection() case .terminated(let exitCode): isExecutionRequestPending = false clearStoppedInspection() if let exitCode { append("Debug session exited with code \(exitCode).\n") } outputPresentation.flush() case .breakpoint(let resolved): + if let reference = resolved.instructionReference { + disassembly.resolveBreakpoint( + reference: reference, + verified: resolved.verified, + message: resolved.message + ) + return + } if let dataID = resolved.dataID, let index = dataBreakpoints.firstIndex(where: { $0.dataID == dataID }) { dataBreakpoints[index].verified = resolved.verified @@ -2148,10 +2294,12 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + /// `preserving` keeps the previous stop's panels on screen until each answer replaces them. private func loadStoppedContext( threadID: Int?, generation: Int, - shouldLoadExceptionInfo: Bool + shouldLoadExceptionInfo: Bool, + preserving: Bool = false ) { guard let session = activeSession else { return } session.requestThreads { [weak self] result in @@ -2170,18 +2318,30 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu generation: generation ) } - self.loadStoppedStack(threadID: selectedThreadID, generation: generation) + self.loadStoppedStack( + threadID: selectedThreadID, + generation: generation, + preserving: preserving + ) + } else if preserving { + self.abandonPreservedInspection() } case .failure(let error): self.record(error) if let threadID { - self.loadStoppedStack(threadID: threadID, generation: generation) + self.loadStoppedStack( + threadID: threadID, + generation: generation, + preserving: preserving + ) + } else if preserving { + self.abandonPreservedInspection() } } } } - private func loadStoppedStack(threadID: Int, generation: Int) { + private func loadStoppedStack(threadID: Int, generation: Int, preserving: Bool = false) { activeSession?.requestStackTrace(threadID: threadID) { [weak self] result in guard let self, self.inspectionGeneration == generation, @@ -2193,16 +2353,24 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let preferredFrame = self.preferredStoppedFrame(in: frames) self.selectedFrameID = preferredFrame?.id if let frame = preferredFrame { - self.selectFrame(frame, generation: generation) + self.selectFrame(frame, generation: generation, preserving: preserving) } else { self.selectedFrame = nil + if preserving { self.abandonPreservedInspection() } } case .failure(let error): self.record(error) + if preserving { self.abandonPreservedInspection() } } } } + /// The new stop has nothing to replace the kept panels with, so they must not outlive it. + private func abandonPreservedInspection() { + guard state == .paused else { return } + clearStoppedInspection() + } + /// Prefer a source-backed, unfiltered frame after a stop so Java's /// synthetic reflection and method-handle frames do not become the user's /// initial inspection location. The full stack remains available and can @@ -2229,6 +2397,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private func beginInspectionTransition() -> Int { inspectionGeneration &+= 1 + disassembly.suspendRequests() activeSession?.cancelPendingOperations() return inspectionGeneration } @@ -2562,7 +2731,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } switch result { case .success(let values): - self.mergeVariablePage( + let additions = self.mergeVariablePage( values, parentVariableID: parentVariableID, requestedFilter: segment.filter, @@ -2572,24 +2741,101 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if expandsParent, let parentVariableID { self.expandedVariableIDs.insert(parentVariableID) } + self.restoreVariableExpansion(of: additions, parentVariableID: parentVariableID) case .failure(let error): self.record(error) } + self.commitVariableRefreshIfSettled() + } + } + + /// Re-expands the nodes of the tree being replaced that the new stop still has, matched by + /// name path because DAP variable ids do not outlive their stop. + private func restoreVariableExpansion(of additions: [DebugVariable], parentVariableID: String?) { + guard let stale = staleVariables else { return } + let parentPath = parentVariableID.flatMap { refreshedVariablePaths[$0] } ?? [] + for variable in additions { + let path = parentPath + [variable.name] + refreshedVariablePaths[variable.id] = path + guard stale.expandedPaths.contains(path), + variable.isExpandable, + variableChildren[variable.id] == nil, + !loadingVariableIDs.contains(variable.id) else { continue } + loadingVariableIDs.insert(variable.id) + variablePageStates[variable.id] = makeVariablePageState( + reference: variable.variablesReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables + ) + requestVariablePage( + parentVariableID: variable.id, + frameID: selectedFrameID, + generation: inspectionGeneration, + expandsParent: true + ) } } + /// Swaps the rebuilt tree in once every page it needed has arrived (or failed). + private func commitVariableRefreshIfSettled() { + guard staleVariables != nil, loadingVariablePageIDs.isEmpty else { return } + staleVariables = nil + refreshedVariablePaths = [:] + } + + private func captureVariableTree() -> GenericDebugVariableSnapshot { + var expandedPaths = Set<[String]>() + var visited = Set() + func walk(_ values: [DebugVariable], prefix: [String]) { + for variable in values where expandedVariableIDs.contains(variable.id) { + guard visited.insert(variable.id).inserted else { continue } + let path = prefix + [variable.name] + expandedPaths.insert(path) + walk(variableChildren[variable.id] ?? [], prefix: path) + } + } + walk(variables, prefix: []) + return GenericDebugVariableSnapshot( + variables: variables, + children: variableChildren, + expanded: expandedVariableIDs, + pageStates: variablePageStates, + expandedPaths: expandedPaths + ) + } + + private var displayedRootVariables: [DebugVariable] { + staleVariables?.variables ?? variables + } + + private func displayedChildren(of variableID: String) -> [DebugVariable]? { + if let staleVariables { return staleVariables.children[variableID] } + return variableChildren[variableID] + } + + private func isDisplayedExpanded(_ variableID: String) -> Bool { + if let staleVariables { return staleVariables.expanded.contains(variableID) } + return expandedVariableIDs.contains(variableID) + } + + private func displayedPageState(_ pageID: String) -> GenericDebugVariablePageState? { + if let staleVariables { return staleVariables.pageStates[pageID] } + return variablePageStates[pageID] + } + + @discardableResult private func mergeVariablePage( _ page: [DebugVariable], parentVariableID: String?, requestedFilter: DebugVariableFilter?, requestedStart: Int, requestedCount: Int - ) { + ) -> [DebugVariable] { let pageID = variablePageID(parentVariableID) guard var state = variablePageStates[pageID], let currentSegment = state.segments.first, currentSegment.filter == requestedFilter, - currentSegment.nextStart == requestedStart else { return } + currentSegment.nextStart == requestedStart else { return [] } let pageFingerprint = page.map { GenericDebugVariablePageItemFingerprint( @@ -2602,7 +2848,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } if !page.isEmpty, !state.loadedPageFingerprints.insert(pageFingerprint).inserted { variablePageStates.removeValue(forKey: pageID) - return + return [] } let existing = parentVariableID.map { variableChildren[$0] ?? [] } ?? variables @@ -2634,6 +2880,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } else { variablePageStates[pageID] = state } + return additions } private func variablePageID(_ parentVariableID: String?) -> String { @@ -2641,6 +2888,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } private func resetVariableTree() { + clearLiveVariableTree() + staleVariables = nil + refreshedVariablePaths = [:] + } + + /// Empties the tree being built without touching the previous stop's tree still on screen. + private func clearLiveVariableTree() { variables = [] variableChildren = [:] expandedVariableIDs = [] @@ -2654,6 +2908,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu automaticExpressionOrder = [] automaticExpressionResults = [:] automaticExpressionFrameID = nil + automaticResultsArePreviousStop = false } private func replaceVariable(_ replacement: DebugVariable) { @@ -2694,7 +2949,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let repeatsAncestor = ancestorVariableIDs.contains(variable.id) || (variable.variablesReference != 0 && ancestorVariableReferences.contains(variable.variablesReference)) - if expandedVariableIDs.contains(variable.id), !repeatsAncestor { + if isDisplayedExpanded(variable.id), !repeatsAncestor { var nextAncestorIDs = ancestorVariableIDs nextAncestorIDs.insert(variable.id) var nextAncestorReferences = ancestorVariableReferences @@ -2702,7 +2957,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu nextAncestorReferences.insert(variable.variablesReference) } appendVisibleVariables( - variableChildren[variable.id] ?? [], + displayedChildren(of: variable.id) ?? [], parentPath: path, depth: depth + 1, to: &rows, @@ -2726,7 +2981,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu to rows: inout [GenericDebugVariableRow] ) { let pageID = variablePageID(parentVariableID) - guard let pageState = variablePageStates[pageID], + guard let pageState = displayedPageState(pageID), !pageState.segments.isEmpty else { return } rows.append(GenericDebugVariableRow( id: "\(parentPath)/load-more", diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugMemoryDisplay.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugMemoryDisplay.swift new file mode 100644 index 0000000000..bd2140bbfe --- /dev/null +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugMemoryDisplay.swift @@ -0,0 +1,200 @@ +import Foundation + +/// How a page of bytes is drawn: the number base, how many bytes make one value, how many +/// bytes a row holds and which byte of a value comes first. It never changes the bytes the +/// adapter returned, and it works on the page's own byte indexes, so the host still does +/// no address arithmetic. +public struct GenericDebugMemoryDisplay: Equatable, Codable, Sendable { + public enum Radix: String, CaseIterable, Codable, Sendable { + case hexadecimal + case unsignedDecimal + case signedDecimal + case octal + case binary + case floatingPoint + } + + public enum ByteOrder: String, CaseIterable, Codable, Sendable { + case little + case big + } + + /// One value on a row. `index` is the first of its bytes in the page data. + public struct Cell: Equatable, Sendable { + public let index: Int + public let byteCount: Int + public let text: String + } + + public struct Row: Identifiable, Equatable, Sendable { + /// Index of the row's first byte in the page data. + public let index: Int + public let cells: [Cell] + /// One character per byte, with a dot for bytes that are not printable ASCII. + public let text: String + + public var id: Int { index } + } + + public static let unitSizes = [1, 2, 4, 8] + public static let rowSizes = [8, 16, 32, 64] + public static let standard = GenericDebugMemoryDisplay() + + public var radix: Radix + public var unitSize: Int + public var bytesPerRow: Int + public var byteOrder: ByteOrder + public var showsText: Bool + + public init( + radix: Radix = .hexadecimal, + unitSize: Int = 1, + bytesPerRow: Int = 16, + byteOrder: ByteOrder = .little, + showsText: Bool = true + ) { + self.radix = radix + self.unitSize = unitSize + self.bytesPerRow = bytesPerRow + self.byteOrder = byteOrder + self.showsText = showsText + } + + // MARK: - Choices + + /// Only 4 and 8 byte values are IEEE floats. + public static func supportsFloatingPoint(unitSize: Int) -> Bool { + unitSize == 4 || unitSize == 8 + } + + /// Picks a base. Floating point needs a 4 or 8 byte value, so it widens a smaller one. + public func selecting(radix: Radix) -> GenericDebugMemoryDisplay { + var result = self + result.radix = radix + if radix == .floatingPoint, !Self.supportsFloatingPoint(unitSize: unitSize) { + result.unitSize = 4 + } + return result.normalized() + } + + /// Picks a value size. A base that does not fit the new size falls back to hexadecimal. + public func selecting(unitSize: Int) -> GenericDebugMemoryDisplay { + var result = self + result.unitSize = unitSize + return result.normalized() + } + + /// Repairs a value that came from storage or from an older build. + public func normalized() -> GenericDebugMemoryDisplay { + var result = self + if !Self.unitSizes.contains(result.unitSize) { result.unitSize = 1 } + if result.radix == .floatingPoint, !Self.supportsFloatingPoint(unitSize: result.unitSize) { + result.radix = .hexadecimal + } + if !Self.rowSizes.contains(result.bytesPerRow) { result.bytesPerRow = 16 } + // Every row size is a multiple of every value size, but a row must hold a whole value. + result.bytesPerRow = max(result.bytesPerRow, result.unitSize) + return result + } + + // MARK: - Persistence + + public var storedText: String { + (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } ?? "" + } + + /// An empty or unreadable text yields the standard layout. + public init(storedText: String) { + let decoded = storedText.data(using: .utf8).flatMap { try? JSONDecoder().decode(Self.self, from: $0) } + self = (decoded ?? .standard).normalized() + } + + // MARK: - Layout + + /// Characters in one value, so columns stay aligned whatever the bytes are. + public var cellWidth: Int { + switch radix { + case .hexadecimal: unitSize * 2 + case .binary: unitSize * 8 + case .unsignedDecimal: Self.decimalWidth(unitSize: unitSize, signed: false) + case .signedDecimal: Self.decimalWidth(unitSize: unitSize, signed: true) + case .octal: (unitSize * 8 + 2) / 3 + case .floatingPoint: unitSize == 4 ? 12 : 17 + } + } + + public var cellsPerRow: Int { bytesPerRow / unitSize } + + /// Characters in a full row of values with a space between them. + public var rowTextWidth: Int { cellsPerRow * cellWidth + (cellsPerRow - 1) } + + public func rows(of data: Data) -> [Row] { + let bytes = Array(data) + return stride(from: 0, to: bytes.count, by: bytesPerRow).map { start in + let end = min(start + bytesPerRow, bytes.count) + var cells: [Cell] = [] + var index = start + while index < end { + let length = min(unitSize, end - index) + // The tail of a page can end inside a value; it is shown as unknown. + let text = length == unitSize + ? format(bytes[index ..< index + length]) + : String(repeating: "?", count: cellWidth) + cells.append(Cell(index: index, byteCount: length, text: text)) + index += unitSize + } + let printable = bytes[start ..< end].map { byte -> Character in + (0x20 ... 0x7e).contains(byte) ? Character(UnicodeScalar(byte)) : "." + } + return Row(index: start, cells: cells, text: String(printable)) + } + } + + // MARK: - Formatting + + private static func decimalWidth(unitSize: Int, signed: Bool) -> Int { + switch (unitSize, signed) { + case (1, false): 3 + case (2, false): 5 + case (4, false): 10 + case (_, false): 20 + case (1, true): 4 + case (2, true): 6 + case (4, true): 11 + case (_, true): 20 + } + } + + private func value(of bytes: ArraySlice) -> UInt64 { + let ordered = byteOrder == .little ? Array(bytes.reversed()) : Array(bytes) + return ordered.reduce(0) { ($0 << 8) | UInt64($1) } + } + + private func format(_ bytes: ArraySlice) -> String { + let raw = value(of: bytes) + let width = cellWidth + switch radix { + case .hexadecimal: + return Self.pad(String(raw, radix: 16), to: width, with: "0") + case .octal: + return Self.pad(String(raw, radix: 8), to: width, with: "0") + case .binary: + return Self.pad(String(raw, radix: 2), to: width, with: "0") + case .unsignedDecimal: + return Self.pad(String(raw), to: width, with: " ") + case .signedDecimal: + let spare = UInt64(64 - bytes.count * 8) + let signed = Int64(bitPattern: raw << spare) >> Int64(spare) + return Self.pad(String(signed), to: width, with: " ") + case .floatingPoint: + let text = bytes.count == 4 + ? String(format: "%.6g", Double(Float(bitPattern: UInt32(truncatingIfNeeded: raw)))) + : String(format: "%.10g", Double(bitPattern: raw)) + return Self.pad(text, to: width, with: " ") + } + } + + private static func pad(_ text: String, to width: Int, with fill: Character) -> String { + String(repeating: fill, count: max(0, width - text.count)) + text + } +} diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index 0091ed0dd5..f7df3f148a 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -175,6 +175,18 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi } } + public func setInstructionBreakpoints(_ breakpoints: [DebugInstructionBreakpoint]) { + guard ownsCoreSession else { return } + do { + try apply(core.setDebugInstructionBreakpoints( + sessionID: sessionID, + breakpoints: breakpoints + )) + } catch { + onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) + } + } + public func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) { guard ownsCoreSession else { return } do { @@ -237,11 +249,41 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi execute(command, threadID: threadID, targetID: targetID, singleThread: false) } + public func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + granularity: DebugSteppingGranularity + ) { + execute( + command, + threadID: threadID, + targetID: nil, + singleThread: false, + granularity: granularity + ) + } + public func execute( _ command: DebugExecutionCommand, threadID: Int?, targetID: Int?, singleThread: Bool + ) { + execute( + command, + threadID: threadID, + targetID: targetID, + singleThread: singleThread, + granularity: nil + ) + } + + private func execute( + _ command: DebugExecutionCommand, + threadID: Int?, + targetID: Int?, + singleThread: Bool, + granularity: DebugSteppingGranularity? ) { guard ownsCoreSession else { return } let operationID = UUID().uuidString @@ -252,7 +294,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi command: command, threadID: threadID, targetID: targetID, - singleThread: singleThread + singleThread: singleThread, + granularity: granularity )) } catch { onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n")) @@ -312,13 +355,82 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi ), line: $0.line, column: $0.column, - isFiltered: $0.isFiltered + isFiltered: $0.isFiltered, + instructionPointerReference: $0.instructionPointerReference ) }) }) } } + public func requestDisassembly( + memoryReference: String, + instructionOffset: Int, + instructionCount: Int, + completion: @escaping (Result<[DebugDisassembledInstruction], Error>) -> Void + ) { + guard capabilities.supportsDisassembleRequest else { + completion(.failure(DebugAdapterCapabilityError.unsupported("disassembly"))) + return + } + inspect( + kind: "disassemble", + memory: DebugCoreMemoryQuery( + reference: memoryReference, + instructionOffset: instructionOffset, + instructionCount: instructionCount, + resolveSymbols: true + ) + ) { result in + completion(result.flatMap { value in + guard value.kind == "disassemble", let instructions = value.instructions else { + return .failure(DebugAdapterProtocolError.invalidResponse("disassemble")) + } + return .success(instructions.map { + DebugDisassembledInstruction( + address: $0.address, + instructionBytes: $0.instructionBytes, + instruction: $0.instruction, + symbol: $0.symbol, + sourceURL: Self.fileURL(forAdapterSourcePath: $0.sourcePath), + line: $0.line + ) + }) + }) + } + } + + public func requestReadMemory( + memoryReference: String, + offset: Int, + count: Int, + completion: @escaping (Result) -> Void + ) { + guard capabilities.supportsReadMemoryRequest else { + completion(.failure(DebugAdapterCapabilityError.unsupported("memory reading"))) + return + } + inspect( + kind: "readMemory", + count: count, + memory: DebugCoreMemoryQuery(reference: memoryReference, offset: offset) + ) { result in + completion(result.flatMap { value in + guard value.kind == "readMemory", + let address = value.address, + let encoded = value.dataBase64, + let data = Data(base64Encoded: encoded) else { + return .failure(DebugAdapterProtocolError.invalidResponse("readMemory")) + } + return .success(DebugMemoryRead( + address: address, + unreadableBytes: value.unreadableBytes ?? 0, + data: data + )) + }) + } + } + public func requestScopes( frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void @@ -335,7 +447,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi variablesReference: scope.variablesReference, expensive: scope.expensive, namedVariables: scope.namedVariables, - indexedVariables: scope.indexedVariables + indexedVariables: scope.indexedVariables, + presentationHint: scope.presentationHint ) }) }) @@ -580,6 +693,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi sourcePath: String? = nil, line: Int? = nil, column: Int? = nil, + memory: DebugCoreMemoryQuery? = nil, completion: @escaping OperationHandler ) { guard ownsCoreSession else { @@ -603,7 +717,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi context: context, sourcePath: sourcePath, line: line, - column: column + column: column, + memory: memory )) } catch { completeOperation(operationID, result: .failure(error)) @@ -675,7 +790,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi column: breakpoint.column, functionName: breakpoint.functionName, dataID: breakpoint.dataID, - requestedLine: breakpoint.requestedLine + requestedLine: breakpoint.requestedLine, + instructionReference: breakpoint.instructionReference ))) case "runInTerminalRequested": guard let requestID = event.requestID, @@ -890,6 +1006,10 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi supportsStepInTargetsRequest: value.supportsStepInTargetsRequest, supportsGotoTargetsRequest: value.supportsGotoTargetsRequest, supportsCompletionsRequest: value.supportsCompletionsRequest, + supportsDisassembleRequest: value.supportsDisassembleRequest, + supportsSteppingGranularity: value.supportsSteppingGranularity, + supportsInstructionBreakpoints: value.supportsInstructionBreakpoints, + supportsReadMemoryRequest: value.supportsReadMemoryRequest, exceptionBreakpointFilters: value.exceptionBreakpointFilters ) } diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 635521a4d7..1a4676db6f 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -327,6 +327,16 @@ public final class DebugAdapterSessionManager: ObservableObject { .forEach { $0.setDataBreakpoints(breakpoints) } } + /// Instruction addresses belong to one running debuggee (ASLR moves them on the next + /// run), so they go to a single session and are never replayed to a later one. + public func setInstructionBreakpoints( + _ breakpoints: [DebugInstructionBreakpoint], + sessionID: DebugSessionID + ) { + (sessions[sessionID]?.session as? any DebugAdapterControllingSession)? + .setInstructionBreakpoints(breakpoints) + } + public func session(providerID: String) -> (any DebugAdapterControllingSession)? { guard let sessionID = activeSessionIDsByProvider[providerID] else { return nil } return sessions[sessionID]?.session as? any DebugAdapterControllingSession diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index d67657774e..eafa86de8a 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -3188,6 +3188,1283 @@ struct DebugModuleTests { #expect(recorder.graphCalls == 2) } + // MARK: - Disassembly, instruction stepping, instruction breakpoints and memory + + private static let disassemblyCapabilities = DebugAdapterCapabilities( + negotiated: true, + supportsDisassembleRequest: true, + supportsSteppingGranularity: true, + supportsInstructionBreakpoints: true, + supportsReadMemoryRequest: true + ) + + /// A Core capability snapshot with everything off except the given flags. + private static func coreCapabilities(_ enabled: [String: Bool]) -> [String: Any] { + var snapshot: [String: Any] = [ + "supportsConfigurationDone": false, + "supportsConditionalBreakpoints": false, + "supportsHitConditionalBreakpoints": false, + "supportsLogPoints": false, + "supportsFunctionBreakpoints": false, + "supportsDataBreakpoints": false, + "supportsExceptionOptions": false, + "supportsExceptionFilterOptions": false, + "supportsSetVariable": false, + "supportsCancelRequest": false, + "supportsSingleThreadExecutionRequests": false, + "supportsRestartRequest": false, + "supportsTerminateRequest": false, + "supportsStepBack": false, + "supportsExceptionInfoRequest": false, + "supportsStepInTargetsRequest": false, + "supportsGotoTargetsRequest": false, + "supportsCompletionsRequest": false, + "exceptionBreakpointFilters": [[String: Any]]() + ] + for (key, value) in enabled { snapshot[key] = value } + return snapshot + } + + private func instruction( + _ address: String, + _ text: String, + symbol: String? = nil + ) -> DebugDisassembledInstruction { + DebugDisassembledInstruction( + address: address, + instructionBytes: nil, + instruction: text, + symbol: symbol + ) + } + + /// A started feature that has received the capabilities the adapter negotiated, as it does + /// right after `initialize` in a real session. + private func makeDisassemblyFeature( + session: DeferredInspectionDebugSession, + rootPath: String + ) -> GenericDebugFeatureModel { + let feature = makeDeferredFeature(session: session, rootPath: rootPath) + session.emit(.capabilities(session.capabilities)) + return feature + } + + /// A feature stopped in a frame that has no source, as in a program built without debug info. + private func makeSourcelessStop( + session: DeferredInspectionDebugSession, + rootPath: String, + address: String = "0x19b5f2a4c" + ) -> (feature: GenericDebugFeatureModel, frame: DebugStackFrame) { + let feature = makeDisassemblyFeature(session: session, rootPath: rootPath) + let frame = DebugStackFrame( + id: 5, + name: "__pthread_kill", + sourceURL: nil, + line: 0, + column: 0, + instructionPointerReference: address + ) + feature.selectThread(DebugThread(id: 1, name: "main")) + session.completeStackTrace(at: 0, with: [frame]) + return (feature, frame) + } + + @Test + func instructionAddressesAreComparedAsTextWithoutArithmetic() { + #expect(DebugAddressText.canonical("0x0000000100003F50") == "100003f50") + #expect(DebugAddressText.canonical("0X0") == "0") + #expect(DebugAddressText.canonical("0x0000") == "0") + #expect(DebugAddressText.areEqual("0x100003f50", "0x0000000100003F50")) + #expect(!DebugAddressText.areEqual("0x100003f50", "0x100003f51")) + // A 64-bit pointer that an Int or a JSON number would round or wrap stays distinct. + #expect(!DebugAddressText.areEqual("0xffffffffffffffff", "0xfffffffffffffffe")) + #expect( + DebugAddressText.firstHexLiteral(in: "0x000000016fdff8a0 -> 0x1") + == "0x000000016fdff8a0" + ) + #expect(DebugAddressText.firstHexLiteral(in: "no pointer here") == nil) + #expect(DebugAddressText.isHexLiteral(" 0xdeadBEEF ")) + #expect(!DebugAddressText.isHexLiteral("rsp")) + #expect(!DebugAddressText.isHexLiteral("0x")) + } + + @Test + func memoryDisplayRowsShowHexBytesAndMaskNonPrintableCharacters() { + let bytes = Array("Hello, lldb!".utf8) + [0x00, 0x7f, 0xff, 0x0a, 0x41] + + let rows = GenericDebugMemoryDisplay.standard.rows(of: Data(bytes)) + #expect(rows.map(\.index) == [0, 16]) + #expect(rows[0].cells.map(\.text).joined(separator: " ") == "48 65 6c 6c 6f 2c 20 6c 6c 64 62 21 00 7f ff 0a") + #expect(rows[0].text == "Hello, lldb!....") + #expect(rows[1].cells.map(\.text) == ["41"]) + #expect(rows[1].text == "A") + #expect(GenericDebugMemoryDisplay.standard.rows(of: Data()).isEmpty) + } + + @Test + func memoryDisplayGroupsBytesIntoValuesOfTheChosenBaseSizeAndOrder() { + let data = Data([0x01, 0x02, 0x03, 0x04, 0xff, 0xff, 0xff, 0xfe]) + + let words = GenericDebugMemoryDisplay(unitSize: 4, bytesPerRow: 8) + #expect(words.rows(of: data)[0].cells.map(\.text) == ["04030201", "feffffff"]) + let bigEndian = GenericDebugMemoryDisplay(unitSize: 4, bytesPerRow: 8, byteOrder: .big) + #expect(bigEndian.rows(of: data)[0].cells.map(\.text) == ["01020304", "fffffffe"]) + + let signed = GenericDebugMemoryDisplay(radix: .signedDecimal, unitSize: 4, bytesPerRow: 8, byteOrder: .big) + #expect(signed.rows(of: data)[0].cells.map { $0.text.trimmingCharacters(in: .whitespaces) } == ["16909060", "-2"]) + let unsigned = GenericDebugMemoryDisplay(radix: .unsignedDecimal, unitSize: 1, bytesPerRow: 8) + #expect(unsigned.rows(of: data)[0].cells.map { $0.text.trimmingCharacters(in: .whitespaces) } + == ["1", "2", "3", "4", "255", "255", "255", "254"]) + let octal = GenericDebugMemoryDisplay(radix: .octal, unitSize: 1, bytesPerRow: 8) + #expect(octal.rows(of: data)[0].cells.map(\.text).prefix(2) == ["001", "002"]) + let binary = GenericDebugMemoryDisplay(radix: .binary, unitSize: 1, bytesPerRow: 8) + #expect(binary.rows(of: data)[0].cells.map(\.text).prefix(2) == ["00000001", "00000010"]) + let float = GenericDebugMemoryDisplay(radix: .floatingPoint, unitSize: 4, bytesPerRow: 8, byteOrder: .big) + #expect(float.rows(of: Data([0x3f, 0x80, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00]))[0].cells + .map { $0.text.trimmingCharacters(in: .whitespaces) } == ["1", "-2"]) + let double = GenericDebugMemoryDisplay(radix: .floatingPoint, unitSize: 8, bytesPerRow: 8) + #expect(double.rows(of: Data([0, 0, 0, 0, 0, 0, 0xf8, 0x3f]))[0].cells[0].text + .trimmingCharacters(in: .whitespaces) == "1.5") + } + + @Test + func memoryDisplayRowsHaveAFixedWidthAndShowAnIncompleteTrailingValueAsUnknown() { + let display = GenericDebugMemoryDisplay(unitSize: 4, bytesPerRow: 16) + let rows = display.rows(of: Data(repeating: 0xab, count: 22)) + #expect(rows.map(\.index) == [0, 16]) + #expect(rows[0].cells.map(\.index) == [0, 4, 8, 12]) + #expect(rows[1].cells.map(\.text) == ["abababab", "????????"]) + #expect(rows[1].cells.last?.byteCount == 2) + #expect(display.rowTextWidth == 4 * 8 + 3) + #expect(rows[0].cells.map(\.text.count) == [8, 8, 8, 8]) + + for radix in GenericDebugMemoryDisplay.Radix.allCases { + for unitSize in GenericDebugMemoryDisplay.unitSizes + where radix != .floatingPoint || GenericDebugMemoryDisplay.supportsFloatingPoint(unitSize: unitSize) { + let sample = GenericDebugMemoryDisplay(radix: radix, unitSize: unitSize, bytesPerRow: 8) + // The widest values must still fit the column, or rows would drift out of line. + let widest = sample.rows(of: Data(repeating: 0xff, count: 8))[0].cells.map(\.text.count) + let narrowest = sample.rows(of: Data(repeating: 0x00, count: 8))[0].cells.map(\.text.count) + #expect(widest.allSatisfy { $0 == sample.cellWidth }) + #expect(narrowest.allSatisfy { $0 == sample.cellWidth }) + } + } + } + + @Test + func memoryDisplayChoicesStayConsistentAndSurviveStorage() { + // Floating point has no 1 or 2 byte form, so choosing it widens the value. + let float = GenericDebugMemoryDisplay.standard.selecting(radix: .floatingPoint) + #expect(float.radix == .floatingPoint) + #expect(float.unitSize == 4) + // Narrowing a float afterwards falls back to a base that has such a value. + #expect(float.selecting(unitSize: 2).radix == .hexadecimal) + #expect(float.selecting(unitSize: 8).radix == .floatingPoint) + + let unusual = GenericDebugMemoryDisplay(radix: .octal, unitSize: 3, bytesPerRow: 12, byteOrder: .big, showsText: false) + let repaired = unusual.normalized() + #expect(repaired.unitSize == 1) + #expect(repaired.bytesPerRow == 16) + #expect(repaired.byteOrder == .big) + #expect(!repaired.showsText) + + let chosen = GenericDebugMemoryDisplay(radix: .signedDecimal, unitSize: 8, bytesPerRow: 32, byteOrder: .big, showsText: false) + #expect(GenericDebugMemoryDisplay(storedText: chosen.storedText) == chosen) + #expect(GenericDebugMemoryDisplay(storedText: "") == .standard) + #expect(GenericDebugMemoryDisplay(storedText: "{not json") == .standard) + #expect(GenericDebugMemoryDisplay(storedText: "{\"radix\":\"decimal-from-the-future\"}") == .standard) + } + + @Test + func memoryDumpKnowsWhereTheRequestedAddressIs() { + func dump(pageOffset: Int, count: Int) -> GenericDebugMemoryDump { + GenericDebugMemoryDump( + reference: "0x1000", + pageOffset: pageOffset, + address: "0x0fc0", + unreadableBytes: 0, + data: Data(repeating: 0, count: count) + ) + } + #expect(dump(pageOffset: -64, count: 256).targetIndex == 64) + #expect(dump(pageOffset: 0, count: 256).targetIndex == 0) + // A page that lies entirely after or before the address does not contain it. + #expect(dump(pageOffset: 256, count: 256).targetIndex == nil) + #expect(dump(pageOffset: -320, count: 256).targetIndex == nil) + // The adapter may return fewer bytes than the page size. + #expect(dump(pageOffset: -64, count: 32).targetIndex == nil) + } + + @Test + func coreProtocolSessionRequestsDisassemblyAndMemoryOnlyAfterNegotiation() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "lldb", + transport: transport, + core: core, + sessionID: "lldb-memory", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + try session.start(rootURL: URL(fileURLWithPath: "/tmp/lldb-memory", isDirectory: true)) + defer { session.stop() } + + var early: Result<[DebugDisassembledInstruction], Error>? + session.requestDisassembly( + memoryReference: "0x100003f50", + instructionOffset: -4, + instructionCount: 8 + ) { early = $0 } + var earlyMemory: Result? + session.requestReadMemory(memoryReference: "0x100003f50", offset: 0, count: 16) { + earlyMemory = $0 + } + #expect(throws: DebugAdapterCapabilityError.self) { try early?.get() } + #expect(throws: DebugAdapterCapabilityError.self) { try earlyMemory?.get() } + #expect(core.inspectionRequests.isEmpty) + + core.enqueueReceive(sessionID: "lldb-memory", state: "ready", events: [[ + "sequence": 2, + "type": "capabilities", + "capabilities": Self.coreCapabilities([ + "supportsDisassembleRequest": true, + "supportsReadMemoryRequest": true, + "supportsSteppingGranularity": true, + "supportsInstructionBreakpoints": true + ]) + ]]) + transport.emitData(Data("initialize-response".utf8)) + #expect(session.capabilities.supportsDisassembleRequest) + #expect(session.capabilities.supportsReadMemoryRequest) + #expect(session.capabilities.supportsSteppingGranularity) + #expect(session.capabilities.supportsInstructionBreakpoints) + + var instructions: Result<[DebugDisassembledInstruction], Error>? + session.requestDisassembly( + memoryReference: "0x100003f50", + instructionOffset: -4, + instructionCount: 8 + ) { instructions = $0 } + #expect(core.inspectionRequests.last?.kind == "disassemble") + #expect(core.inspectionRequests.last?.memory == DebugCoreMemoryQuery( + reference: "0x100003f50", + instructionOffset: -4, + instructionCount: 8, + resolveSymbols: true + )) + let disassembleID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "lldb-memory", state: "paused", events: [[ + "sequence": 3, + "type": "operationCompleted", + "operationId": disassembleID, + "result": [ + "kind": "disassemble", + "instructions": [[ + "address": "0x100003f48", + "instructionBytes": "fd 7b bf a9", + "instruction": "stp x29, x30, [sp, #-0x10]!", + "symbol": "main" + ], [ + "address": "0x100003f4c", + "instruction": "mov x29, sp", + "sourcePath": "/tmp/lldb-memory/main.c", + "line": 4 + ]] + ] + ]]) + transport.emitData(Data("disassemble-response".utf8)) + let rows = try #require(try instructions?.get()) + #expect(rows.map(\.address) == ["0x100003f48", "0x100003f4c"]) + #expect(rows[0].instructionBytes == "fd 7b bf a9") + #expect(rows[0].symbol == "main") + #expect(rows[1].instructionBytes == nil) + #expect(rows[1].sourceURL?.lastPathComponent == "main.c") + #expect(rows[1].line == 4) + + var memory: Result? + session.requestReadMemory(memoryReference: "0x100003f50", offset: 16, count: 4) { + memory = $0 + } + #expect(core.inspectionRequests.last?.kind == "readMemory") + #expect(core.inspectionRequests.last?.count == 4) + #expect(core.inspectionRequests.last?.memory == DebugCoreMemoryQuery( + reference: "0x100003f50", + offset: 16 + )) + let readID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "lldb-memory", state: "paused", events: [[ + "sequence": 4, + "type": "operationCompleted", + "operationId": readID, + "result": [ + "kind": "readMemory", + "address": "0x100003f60", + "unreadableBytes": 2, + "dataBase64": Data([1, 2, 3, 4]).base64EncodedString() + ] + ]]) + transport.emitData(Data("read-memory-response".utf8)) + let read = try #require(try memory?.get()) + #expect(read.address == "0x100003f60") + #expect(read.unreadableBytes == 2) + #expect(read.data == Data([1, 2, 3, 4])) + } + + @Test + func coreProtocolSessionForwardsInstructionBreakpointsAndSteppingGranularity() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "lldb", + transport: transport, + core: core, + sessionID: "lldb-instruction-steps", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + try session.start(rootURL: URL(fileURLWithPath: "/tmp/lldb-steps", isDirectory: true)) + defer { session.stop() } + + session.setInstructionBreakpoints([ + DebugInstructionBreakpoint(instructionReference: "0x100003f50") + ]) + #expect(core.instructionBreakpointUpdates.map { $0.map(\.instructionReference) } == [ + ["0x100003f50"] + ]) + + session.execute(.next, threadID: 1, granularity: .instruction) + #expect(core.lastExecutionGranularity == .instruction) + session.execute(.next, threadID: 1) + #expect(core.lastExecutionGranularity == nil) + } + + @Test + func coreProtocolSessionMapsSourcelessFramesAndRegisterScopes() throws { + let transport = RecordingTransport() + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "lldb", + transport: transport, + core: core, + sessionID: "lldb-sourceless", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + try session.start(rootURL: URL(fileURLWithPath: "/tmp/lldb-sourceless", isDirectory: true)) + defer { session.stop() } + + var frames: Result<[DebugStackFrame], Error>? + session.requestStackTrace(threadID: 1) { frames = $0 } + let stackID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "lldb-sourceless", state: "paused", events: [[ + "sequence": 2, + "type": "operationCompleted", + "operationId": stackID, + "result": [ + "kind": "stackTrace", + "stackFrames": [[ + "id": 1, + "name": "__pthread_kill", + "line": 0, + "column": 0, + "instructionPointerReference": "0x19b5f2a4c" + ], [ + // lldb-dap serves code without debug info as a synthetic source. + "id": 3, + "name": "raise", + "sourcePath": "/usr/lib/system/libsystem_c.dylib`raise", + "sourceReference": 4, + "line": 12, + "column": 1, + "instructionPointerReference": "0x19b5f2b00" + ], [ + "id": 2, + "name": "main", + "sourcePath": "/tmp/lldb-sourceless/main.c", + "line": 3, + "column": 1 + ]] + ] + ]]) + transport.emitData(Data("stack-response".utf8)) + let stack = try #require(try frames?.get()) + #expect(stack[0].isSourceless) + #expect(stack[0].sourceURL == nil) + #expect(stack[0].instructionPointerReference == "0x19b5f2a4c") + #expect(stack[1].isSourceless) + #expect(stack[1].sourceURL == nil) + #expect(stack[1].instructionPointerReference == "0x19b5f2b00") + #expect(!stack[2].isSourceless) + #expect(stack[2].instructionPointerReference == nil) + + var scopes: Result<[DebugScope], Error>? + session.requestScopes(frameID: 1) { scopes = $0 } + let scopesID = try #require(core.lastInspectionOperationID) + core.enqueueReceive(sessionID: "lldb-sourceless", state: "paused", events: [[ + "sequence": 3, + "type": "operationCompleted", + "operationId": scopesID, + "result": [ + "kind": "scopes", + "scopes": [[ + "name": "Variables", + "variablesReference": 9, + "expensive": false + ], [ + "name": "Registers", + "variablesReference": 10, + "expensive": true, + "presentationHint": "registers" + ]] + ] + ]]) + transport.emitData(Data("scopes-response".utf8)) + let mapped = try #require(try scopes?.get()) + #expect(mapped.map(\.isRegisters) == [false, true]) + } + + @Test + func sourcelessFrameOpensTheDisassemblyAndASourceFrameClosesItAgain() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let feature = makeDisassemblyFeature(session: session, rootPath: "/tmp/lldb-disassembly-follow") + defer { feature.stop() } + let source = URL(fileURLWithPath: "/tmp/lldb-disassembly-follow/src/main.c") + let library = DebugStackFrame( + id: 1, + name: "__pthread_kill", + sourceURL: nil, + line: 0, + column: 0, + instructionPointerReference: "0x19b5f2a4c" + ) + let user = DebugStackFrame( + id: 2, + name: "main", + sourceURL: source, + line: 4, + column: 1, + instructionPointerReference: "0x100003f50" + ) + + feature.selectThread(DebugThread(id: 1, name: "main")) + session.completeStackTrace(at: 0, with: [library, user]) + // The first frame with source is preferred, so the view stays closed. + #expect(feature.selectedFrameID == 2) + #expect(!feature.disassembly.isPresented) + #expect(session.disassemblyReferences.isEmpty) + + feature.selectFrame(library) + #expect(feature.disassembly.isPresented) + #expect(feature.disassembly.currentAddress == "0x19b5f2a4c") + #expect(feature.disassembly.isLoading) + #expect(session.disassemblyReferences == ["0x19b5f2a4c"]) + #expect(session.disassemblyOffsets == [-GenericDebugDisassemblyModel.instructionsBefore]) + + let rows = [ + instruction("0x19b5f2a48", "mov x16, #0x148", symbol: "__pthread_kill"), + instruction("0x19b5f2a4c", "svc #0x80") + ] + session.completeDisassembly(at: 0, with: .success(rows)) + #expect(feature.disassembly.rows == rows) + #expect(!feature.disassembly.isLoading) + #expect(feature.disassembly.isCurrent(rows[1])) + #expect(!feature.disassembly.isCurrent(rows[0])) + + // A frame with source returns to the editor, so the view that opened itself closes. + feature.selectFrame(user) + #expect(!feature.disassembly.isPresented) + #expect(feature.disassembly.rows.isEmpty) + + // Opened by hand, the view stays while other frames are selected. + feature.disassembly.present() + #expect(session.disassemblyReferences == ["0x19b5f2a4c", "0x100003f50"]) + feature.selectFrame(library) + feature.selectFrame(user) + #expect(feature.disassembly.isPresented) + } + + @Test + func disassemblyStartsAtTheInstructionPointerWhenTheAdapterRefusesNegativeOffsets() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-no-negative") + defer { feature.stop() } + + #expect(feature.disassembly.isPresented) + #expect(session.disassemblyOffsets == [-GenericDebugDisassemblyModel.instructionsBefore]) + session.completeDisassembly(at: 0, with: .failure(DeferredDebugSessionError.launchFailed)) + #expect(session.disassemblyOffsets == [-GenericDebugDisassemblyModel.instructionsBefore, 0]) + #expect(feature.disassembly.isLoading) + #expect(feature.disassembly.loadFailure == nil) + + let rows = [instruction("0x19b5f2a4c", "svc #0x80")] + session.completeDisassembly(at: 1, with: .success(rows)) + #expect(feature.disassembly.rows == rows) + #expect(!feature.disassembly.isLoading) + + // A failure of a request that was not the first window is reported, not retried. + feature.disassembly.loadMore(.after) + #expect(session.disassemblyReferences.last == "0x19b5f2a4c") + #expect(session.disassemblyOffsets.last == 1) + session.completeDisassembly(at: 2, with: .failure(DeferredDebugSessionError.launchFailed)) + #expect(feature.disassembly.loadFailure != nil) + #expect(!feature.disassembly.isLoading) + #expect(feature.disassembly.rows == rows) + #expect(session.disassemblyOffsets.count == 3) + } + + @Test + func loadingMoreInstructionsAddsOnlyAddressesNotListedYet() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-load-more") + defer { feature.stop() } + + let first = instruction("0x19b5f2a48", "mov x16, #0x148") + let second = instruction("0x19b5f2a4c", "svc #0x80") + session.completeDisassembly(at: 0, with: .success([first, second])) + + feature.disassembly.loadMore(.before) + #expect(session.disassemblyReferences.last == "0x19b5f2a48") + #expect(session.disassemblyOffsets.last == -GenericDebugDisassemblyModel.pageInstructionCount) + let older = instruction("0x19b5f2a44", "nop") + // The adapter may repeat the edge; the same address in a different spelling is the same row. + let repeated = instruction("0x0000000019b5f2a48", "mov x16, #0x148") + session.completeDisassembly(at: 1, with: .success([older, repeated])) + #expect(feature.disassembly.rows == [older, first, second]) + + feature.disassembly.loadMore(.after) + #expect(session.disassemblyReferences.last == "0x19b5f2a4c") + #expect(session.disassemblyOffsets.last == 1) + let newer = instruction("0x19b5f2a50", "ret") + session.completeDisassembly(at: 2, with: .success([newer])) + #expect(feature.disassembly.rows == [older, first, second, newer]) + } + + @Test + func disassemblyReplyAfterTheDebuggeeResumedIsDropped() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-stale-disassembly") + defer { feature.stop() } + #expect(feature.disassembly.isLoading) + + session.emit(.continued(threadID: 1)) + #expect(!feature.disassembly.isLoading) + #expect(feature.disassembly.currentAddress == nil) + + // Addresses of the previous stop mean nothing now, even if the reply still arrives. + session.completeDisassembly(at: 0, with: .success([instruction("0x19b5f2a4c", "svc #0x80")])) + #expect(feature.disassembly.rows.isEmpty) + #expect(feature.disassembly.loadFailure == nil) + } + + @Test + func steppingMovesOneInstructionWhileTheDisassemblyIsOpenAndTheAdapterCan() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let feature = makeDisassemblyFeature(session: session, rootPath: "/tmp/lldb-instruction-step") + defer { feature.stop() } + func resume() { + session.transition(to: .running) + session.transition(to: .paused) + } + + feature.execute(.next) + #expect(session.executionCommands == [.next]) + #expect(session.steppingGranularities.isEmpty) + resume() + + feature.disassembly.present() + feature.execute(.next) + resume() + feature.execute(.stepIn) + resume() + // Leaving the function is not an instruction-sized move. + feature.execute(.stepOut) + resume() + #expect(session.executionCommands == [.next, .next, .stepIn, .stepOut]) + #expect(session.steppingGranularities == [.instruction, .instruction]) + + feature.disassembly.dismiss() + feature.execute(.next) + #expect(session.steppingGranularities == [.instruction, .instruction]) + #expect(session.executionCommands.count == 5) + + let plain = DeferredInspectionDebugSession( + capabilities: DebugAdapterCapabilities(negotiated: true, supportsDisassembleRequest: true) + ) + let plainFeature = makeDisassemblyFeature(session: plain, rootPath: "/tmp/lldb-no-granularity") + defer { plainFeature.stop() } + plainFeature.disassembly.present() + plainFeature.execute(.next) + #expect(plain.executionCommands == [.next]) + #expect(plain.steppingGranularities.isEmpty) + } + + @Test + func instructionBreakpointsAreSetThroughTheSessionAndMatchedByAddressText() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let feature = makeDisassemblyFeature(session: session, rootPath: "/tmp/lldb-instruction-breakpoints") + defer { feature.stop() } + + feature.disassembly.toggleBreakpoint(at: "0x100003f50") + #expect(session.instructionBreakpointUpdates.map { $0.map(\.instructionReference) } == [ + ["0x100003f50"] + ]) + #expect(feature.disassembly.breakpoint(at: "0x0000000100003F50") != nil) + #expect(feature.disassembly.breakpoints.first?.verified == false) + + // The adapter may spell the address differently from the one that was requested. + session.emit(.breakpoint(DebugBreakpoint( + id: 1, + verified: true, + message: nil, + sourceURL: nil, + line: nil, + column: nil, + instructionReference: "0x0000000100003f50" + ))) + #expect(feature.disassembly.breakpoint(at: "0x100003f50")?.verified == true) + + session.emit(.breakpoint(DebugBreakpoint( + id: 1, + verified: false, + message: "no code at this address", + sourceURL: nil, + line: nil, + column: nil, + instructionReference: "0x100003f50" + ))) + #expect(feature.disassembly.breakpoint(at: "0x100003f50")?.verified == false) + #expect(feature.disassembly.breakpoint(at: "0x100003f50")?.message == "no code at this address") + + // Clicking the address again clears it, whichever way the address is spelled. + feature.disassembly.toggleBreakpoint(at: "0x0000000100003F50") + #expect(feature.disassembly.breakpoints.isEmpty) + #expect(session.instructionBreakpointUpdates.last?.isEmpty == true) + #expect(session.instructionBreakpointUpdates.count == 2) + } + + @Test + func instructionBreakpointsBelongToOneSessionAndAreForgottenWithIt() { + final class Sink { + var sessions: [DebugSessionID] = [] + } + let sink = Sink() + let model = GenericDebugDisassemblyModel() + model.environment = GenericDebugDisassemblyModel.Environment( + session: { nil }, + capabilities: { Self.disassemblyCapabilities }, + selectedFrameID: { nil }, + breakpointsChanged: { id, _ in sink.sessions.append(id) } + ) + let first = DebugSessionID() + let second = DebugSessionID() + + model.activate(sessionID: first) + model.toggleBreakpoint(at: "0x10") + model.activate(sessionID: second) + #expect(model.breakpoints.isEmpty) + model.toggleBreakpoint(at: "0x20") + #expect(sink.sessions == [first, second]) + + // ASLR gives the next run other addresses, so nothing outlives its session. + model.activate(sessionID: first) + #expect(model.breakpoints.map(\.address) == ["0x10"]) + model.forget(sessionID: first) + #expect(model.breakpoints.isEmpty) + model.activate(sessionID: second) + model.activate(sessionID: first) + #expect(model.breakpoints.isEmpty) + } + + @Test + func disassemblyActionsAreInertWithoutTheMatchingAdapterCapability() throws { + let session = DeferredInspectionDebugSession() + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-no-capabilities") + defer { feature.stop() } + + #expect(!feature.disassembly.isPresented) + feature.disassembly.present() + feature.disassembly.toggleBreakpoint(at: "0x10") + feature.disassembly.readMemory(from: "0x1000") + #expect(session.disassemblyReferences.isEmpty) + #expect(session.instructionBreakpointUpdates.isEmpty) + #expect(session.memoryReferences.isEmpty) + #expect(feature.disassembly.breakpoints.isEmpty) + #expect(!feature.disassembly.canDisassemble) + #expect(!feature.disassembly.canReadMemory) + #expect(!feature.disassembly.stepsByInstruction) + } + + @Test + func memoryReadsAcceptAnAddressOrAnExpressionAndPageByOffset() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, frame) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-read-memory") + defer { feature.stop() } + let page = GenericDebugMemoryDump.pageSize + let context = GenericDebugMemoryDump.contextBytes + + // The page starts before the address, so the address is not the first thing on it. + feature.disassembly.readMemory(from: " 0x1000 ") + #expect(session.memoryReferences == ["0x1000"]) + #expect(session.memoryOffsets == [-context]) + #expect(feature.disassembly.isLoadingMemory) + let revealBefore = feature.disassembly.memoryRevealToken + session.completeMemory(at: 0, with: .success(DebugMemoryRead( + address: "0x0fc0", + unreadableBytes: 0, + data: Data(repeating: 0x41, count: page) + ))) + #expect(!feature.disassembly.isLoadingMemory) + #expect(feature.disassembly.memory?.pageOffset == -context) + #expect(feature.disassembly.memory?.targetIndex == context) + #expect(feature.disassembly.memoryRevealToken == revealBefore + 1) + + feature.disassembly.showMemoryPage(.after) + #expect(session.memoryReferences == ["0x1000", "0x1000"]) + #expect(session.memoryOffsets == [-context, -context + page]) + session.completeMemory(at: 1, with: .success(DebugMemoryRead( + address: "0x10c0", + unreadableBytes: 16, + data: Data(repeating: 0x42, count: 8) + ))) + #expect(feature.disassembly.memory?.pageOffset == -context + page) + #expect(feature.disassembly.memory?.unreadableBytes == 16) + #expect(feature.disassembly.memory?.targetIndex == nil) + feature.disassembly.showMemoryPage(.before) + #expect(session.memoryOffsets.last == -context) + session.completeMemory(at: 2, with: .failure(DeferredDebugSessionError.launchFailed)) + #expect(feature.disassembly.memoryFailure != nil) + + // A register or any other expression is evaluated in the selected frame first. + feature.disassembly.readMemory(from: "$sp") + #expect(session.evaluateExpressions == ["$sp"]) + #expect(session.evaluateFrameIDs == [frame.id]) + session.completeEvaluation(at: 0, with: .success(DebugVariable( + id: "$sp", + name: "$sp", + value: "0x000000016fdff8a0", + type: "unsigned long", + evaluateName: "$sp", + variablesReference: 0 + ))) + #expect(session.memoryReferences.last == "0x000000016fdff8a0") + #expect(session.memoryOffsets.last == -context) + // Nothing is readable before the start of a mapping; the page then starts at the address. + session.completeMemory(at: 3, with: .failure(DeferredDebugSessionError.launchFailed)) + #expect(session.memoryOffsets.last == 0) + #expect(feature.disassembly.memoryFailure == nil) + #expect(feature.disassembly.isLoadingMemory) + session.completeMemory(at: 4, with: .success(DebugMemoryRead( + address: "0x000000016fdff8a0", + unreadableBytes: 0, + data: Data(repeating: 0, count: page) + ))) + #expect(feature.disassembly.memory?.targetIndex == 0) + #expect(!feature.disassembly.isLoadingMemory) + + // A value that holds no address is reported instead of guessed at. + feature.disassembly.readMemory(from: "argc") + session.completeEvaluation(at: 1, with: .success(DebugVariable( + id: "argc", + name: "argc", + value: "3", + type: "int", + evaluateName: "argc", + variablesReference: 0 + ))) + #expect(feature.disassembly.memoryFailure == .unresolvedAddress) + #expect(!feature.disassembly.isLoadingMemory) + // Five reads so far: first page, next, previous, `$sp` with context, and its retry from the address. + // The unresolved `argc` adds none. + #expect(session.memoryReferences.count == 5) + } + + @Test + func selectingAnotherFrameDropsMemoryOfThePreviousOne() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, frame) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-memory-frame") + defer { feature.stop() } + + feature.disassembly.readMemory(from: "0x1000") + feature.selectFrame(frame) + // The reply of the read started before the selection belongs to the old frame. + session.completeMemory(at: 0, with: .success(DebugMemoryRead( + address: "0x1000", + unreadableBytes: 0, + data: Data([1, 2, 3]) + ))) + #expect(feature.disassembly.memory == nil) + #expect(!feature.disassembly.isLoadingMemory) + + feature.disassembly.readMemory(from: "0x2000") + session.completeMemory(at: 1, with: .success(DebugMemoryRead( + address: "0x2000", + unreadableBytes: 0, + data: Data([4]) + ))) + #expect(feature.disassembly.memory?.address == "0x2000") + feature.selectFrame(frame) + #expect(feature.disassembly.memory == nil) + } + + // MARK: - Stepping refreshes the panels in place + + /// A feature stopped once in `main`, with `point` expanded next to `count`, ready for a step. + private func makeSteppableStop( + session: DeferredInspectionDebugSession, + rootPath: String + ) -> (feature: GenericDebugFeatureModel, point: DebugVariable) { + let feature = makeDeferredFeature(session: session, rootPath: rootPath) + let frame = DebugStackFrame( + id: 70, + name: "main", + sourceURL: URL(fileURLWithPath: "\(rootPath)/src/Main.java"), + line: 10, + column: 1 + ) + let point = DebugVariable( + id: "locals/point", name: "point", value: "{x=1, y=2}", type: "Point", + evaluateName: "point", variablesReference: 71 + ) + let count = DebugVariable( + id: "locals/count", name: "count", value: "1", type: "int", + evaluateName: "count", variablesReference: 0 + ) + feature.selectThread(DebugThread(id: 1, name: "main")) + session.completeStackTrace(at: 0, with: [frame]) + session.completeScopes( + at: 0, + with: [DebugScope(id: 700, name: "Locals", variablesReference: 700, expensive: false)] + ) + session.completeVariables(at: 0, with: [point, count]) + feature.toggleVariableExpansion(point) + session.completeVariables(at: 1, with: [ + DebugVariable(id: "point/x", name: "x", value: "1", type: "int", evaluateName: "point.x", variablesReference: 0), + DebugVariable(id: "point/y", name: "y", value: "2", type: "int", evaluateName: "point.y", variablesReference: 0) + ]) + return (feature, point) + } + + /// The adapter reports a step: it resumes, stops in the same thread, then pauses. + private func landStep(_ session: DeferredInspectionDebugSession) { + session.emit(.stopped(reason: "step", threadID: 1, description: nil)) + session.transition(to: .paused) + } + + @Test + func steppingKeepsTheVariableTreeAndSwapsItInPlaceAtTheNextStop() throws { + let session = DeferredInspectionDebugSession() + let (feature, point) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-in-place") + defer { feature.stop() } + #expect(feature.isVariableExpanded(point)) + #expect(feature.children(of: point).map(\.name) == ["x", "y"]) + let before = feature.presentedVariables + + feature.execute(.next) + session.transition(to: .running) + + // The step is in flight: nothing was cleared, the panels are only marked as outdated. + #expect(feature.isInspectionStale) + #expect(feature.stackFrames.count == 1) + #expect(feature.presentedVariables == before) + #expect(feature.isVariableExpanded(point)) + #expect(feature.children(of: point).map(\.name) == ["x", "y"]) + + landStep(session) + #expect(!feature.isInspectionStale) + #expect(feature.stackFrames.count == 1, "The previous stack stays until the new one arrives") + #expect(feature.presentedVariables == before) + + session.completeThreads(at: 0, with: [DebugThread(id: 1, name: "main")]) + session.completeStackTrace(at: 1, with: [ + DebugStackFrame( + id: 80, + name: "main", + sourceURL: URL(fileURLWithPath: "/tmp/debug-step-in-place/src/Main.java"), + line: 11, + column: 1 + ) + ]) + #expect(feature.selectedFrameID == 80) + #expect(feature.presentedVariables == before) + session.completeScopes( + at: 1, + with: [DebugScope(id: 800, name: "Locals", variablesReference: 800, expensive: false)] + ) + #expect(feature.isRefreshingVariables) + #expect(session.variableReferences.last == 800) + #expect(feature.presentedVariables == before) + + // The new root page arrives behind the old tree, which is still what the view shows. + let newPoint = DebugVariable( + id: "locals2/point", name: "point", value: "{x=5, y=2}", type: "Point", + evaluateName: "point", variablesReference: 81 + ) + let newCount = DebugVariable( + id: "locals2/count", name: "count", value: "2", type: "int", + evaluateName: "count", variablesReference: 0 + ) + session.completeVariables(at: 2, with: [newPoint, newCount]) + #expect(feature.isRefreshingVariables, "`point` was expanded, so its children are fetched again") + #expect(session.variableReferences.last == 81) + #expect(feature.presentedVariables == before) + + session.completeVariables(at: 3, with: [ + DebugVariable(id: "point2/x", name: "x", value: "5", type: "int", evaluateName: "point.x", variablesReference: 0), + DebugVariable(id: "point2/y", name: "y", value: "2", type: "int", evaluateName: "point.y", variablesReference: 0) + ]) + #expect(!feature.isRefreshingVariables) + #expect(feature.presentedVariables == [newPoint, newCount]) + #expect(feature.isVariableExpanded(newPoint), "Expansion survives by name path, not by the adapter's ids") + #expect(feature.children(of: newPoint).map(\.value) == ["5", "2"]) + } + + @Test + func theVariableTreeCannotBeEditedWhileItIsBeingReplaced() async throws { + let session = DeferredInspectionDebugSession() + let (feature, point) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-readonly") + defer { feature.stop() } + let requestsBefore = session.variableReferences.count + + feature.execute(.next) + session.transition(to: .running) + feature.toggleVariableExpansion(point) + feature.loadMoreVariables(parentVariableID: nil) + #expect(feature.isVariableExpanded(point), "A click on the frozen tree must not collapse it") + #expect(session.variableReferences.count == requestsBefore) + + // A JSON snapshot of a tree the debuggee already left would show values that no longer exist. + await #expect(throws: CancellationError.self) { + _ = try await feature.variableJSONSnapshot() + } + } + + @Test + func thePreviousTreeStaysReadOnlyBeforeTheNewScopesArrive() async throws { + let session = DeferredInspectionDebugSession() + let (feature, point) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-scope-gap") + defer { feature.stop() } + let requestsBefore = session.variableReferences.count + + feature.execute(.next) + session.transition(to: .running) + landStep(session) + #expect(feature.isRefreshingVariables) + feature.toggleVariableExpansion(point) + feature.loadMoreVariables(parentVariableID: nil) + #expect(feature.isVariableExpanded(point)) + #expect(session.variableReferences.count == requestsBefore) + await #expect(throws: CancellationError.self) { + _ = try await feature.variableJSONSnapshot() + } + + session.completeThreads(at: 0, with: [DebugThread(id: 1, name: "main")]) + session.completeStackTrace(at: 1, with: [DebugStackFrame( + id: 80, name: "main", sourceURL: nil, line: 0, column: 0 + )]) + feature.toggleVariableExpansion(point) + #expect(feature.isVariableExpanded(point)) + #expect(session.variableReferences.count == requestsBefore) + } + + @Test + func cancellingInspectionForAStepKeepsMemoryAndDoesNotRetryOldDisassembly() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/debug-step-cancel-inspection") + defer { feature.stop() } + feature.disassembly.readMemory(reference: "0x1000", pageOffset: 0) + session.completeMemory(at: 0, with: .success(DebugMemoryRead( + address: "0x1000", unreadableBytes: 0, data: Data([1, 2, 3]) + ))) + feature.disassembly.readMemory(reference: "0x1000", pageOffset: 0) + session.onCancelPendingOperations = { + session.completeDisassembly(at: 0, with: .failure(DebugAdapterProtocolError.cancelled("disassemble"))) + session.completeMemory(at: 1, with: .failure(DebugAdapterProtocolError.cancelled("readMemory"))) + } + + feature.execute(.next) + session.transition(to: .running) + #expect(session.disassemblyOffsets.count == 1, "Cancellation must not retry the previous stop's request") + #expect(feature.disassembly.loadFailure == nil) + #expect(feature.disassembly.memoryFailure == nil) + #expect(feature.disassembly.memory?.data == Data([1, 2, 3])) + #expect(!feature.disassembly.isLoading) + #expect(!feature.disassembly.isLoadingMemory) + } + + @Test + func aLateDisassemblyFailureCannotRetryInAnotherSession() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/debug-disassembly-late-failure") + defer { feature.stop() } + + feature.disassembly.activate(sessionID: DebugSessionID()) + session.completeDisassembly(at: 0, with: .failure(DebugAdapterProtocolError.cancelled("disassemble"))) + #expect(session.disassemblyOffsets.count == 1) + #expect(feature.disassembly.rows.isEmpty) + #expect(feature.disassembly.loadFailure == nil) + } + + @Test + func continuingStillClearsTheStoppedInspection() throws { + let session = DeferredInspectionDebugSession() + let (feature, _) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-continue") + defer { feature.stop() } + + // Continue can end anywhere, so it keeps the full reset instead of the in-place swap. + feature.execute(.continueExecution) + session.transition(to: .running) + + #expect(!feature.isInspectionStale) + #expect(feature.stackFrames.isEmpty) + #expect(feature.presentedVariables.isEmpty) + #expect(feature.selectedFrameID == nil) + } + + @Test + func terminatingWhileSteppingClearsThePanels() throws { + let session = DeferredInspectionDebugSession() + let (feature, _) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-terminated") + defer { feature.stop() } + + feature.execute(.stepIn) + session.transition(to: .running) + #expect(feature.isInspectionStale) + session.emit(.terminated(exitCode: 0)) + session.transition(to: .terminated) + + #expect(!feature.isInspectionStale) + #expect(!feature.isRefreshingVariables) + #expect(feature.stackFrames.isEmpty) + #expect(feature.presentedVariables.isEmpty) + } + + @Test + func failingWhileSteppingClearsTheRetainedInspectionAndLateReplies() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/debug-step-failure") + defer { feature.stop() } + session.completeDisassembly(at: 0, with: .success([instruction("0x19b5f2a4c", "nop")])) + feature.disassembly.readMemory(reference: "0x1000", pageOffset: 0) + session.completeMemory(at: 0, with: .success(DebugMemoryRead( + address: "0x1000", unreadableBytes: 0, data: Data([1, 2, 3]) + ))) + feature.execute(.next) + session.transition(to: .running) + session.fail() + session.completeDisassembly(at: 0, with: .success([instruction("0x19b5f2a4c", "nop")])) + #expect(feature.state == .failed) + #expect(feature.stackFrames.isEmpty) + #expect(feature.selectedFrameID == nil) + #expect(feature.disassembly.rows.isEmpty) + #expect(feature.disassembly.memory == nil) + #expect(!feature.isRefreshingVariables) + } + + @Test + func aStopWithoutFramesDoesNotLeaveTheOldStackOnScreen() throws { + let session = DeferredInspectionDebugSession() + let (feature, _) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-empty-stack") + defer { feature.stop() } + + feature.execute(.next) + session.transition(to: .running) + landStep(session) + session.completeThreads(at: 0, with: [DebugThread(id: 1, name: "main")]) + session.completeStackTrace(at: 1, with: []) + + // Nothing replaces the kept panels, so they must not pretend to describe this stop. + #expect(feature.selectedFrameID == nil) + #expect(feature.stackFrames.isEmpty) + #expect(feature.presentedVariables.isEmpty) + #expect(!feature.isRefreshingVariables) + } + + @Test + func aStopThatReportsNoThreadFallsBackToAFullReset() throws { + let session = DeferredInspectionDebugSession() + let (feature, _) = makeSteppableStop(session: session, rootPath: "/tmp/debug-step-no-threads") + defer { feature.stop() } + + feature.execute(.next) + session.transition(to: .running) + session.emit(.stopped(reason: "step", threadID: nil, description: nil)) + session.transition(to: .paused) + session.completeThreads(at: 0, with: []) + + #expect(feature.stackFrames.isEmpty) + #expect(feature.presentedVariables.isEmpty) + #expect(!feature.isInspectionStale) + } + + @Test + func automaticVariablesKeepTheirOldValuesUntilEachOneIsEvaluatedAgain() throws { + let session = DeferredInspectionDebugSession() + let feature = makeDeferredFeature(session: session, rootPath: "/tmp/debug-step-automatic") + defer { feature.stop() } + feature.onAutomaticVariableInspectionRequest = { [weak feature] _ in + feature?.requestAutomaticVariables(["total"]) + } + func frame(_ id: Int, line: Int) -> DebugStackFrame { + DebugStackFrame( + id: id, + name: "main", + sourceURL: URL(fileURLWithPath: "/tmp/debug-step-automatic/src/Main.java"), + line: line, + column: 1 + ) + } + func total(_ value: String) -> DebugVariable { + DebugVariable( + id: "total", name: "total", value: value, type: "int", + evaluateName: "total", variablesReference: 0 + ) + } + + feature.selectThread(DebugThread(id: 1, name: "main")) + session.completeStackTrace(at: 0, with: [frame(1, line: 5)]) + session.completeEvaluation(at: 0, with: .success(total("3"))) + #expect(feature.automaticVariables.map(\.value) == ["3"]) + + feature.execute(.next) + session.transition(to: .running) + landStep(session) + session.completeThreads(at: 0, with: [DebugThread(id: 1, name: "main")]) + session.completeStackTrace(at: 1, with: [frame(2, line: 6)]) + + // The expression is asked again for the new frame while the old value stays visible. + #expect(session.evaluateFrameIDs.last == 2) + #expect(feature.automaticVariables.map(\.value) == ["3"]) + session.completeEvaluation(at: 1, with: .success(total("4"))) + #expect(feature.automaticVariables.map(\.value) == ["4"]) + } + + // MARK: - Stepping in the disassembly view + + @Test + func stepButtonsKnowWhetherTheyMoveByInstruction() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let feature = makeDisassemblyFeature(session: session, rootPath: "/tmp/lldb-step-granularity-flags") + defer { feature.stop() } + + #expect(feature.disassembly.canStepByInstruction) + #expect(!feature.disassembly.stepsByInstruction, "Closed view: a step still moves by source line") + feature.disassembly.present() + #expect(feature.disassembly.stepsByInstruction) + feature.disassembly.dismiss() + #expect(!feature.disassembly.stepsByInstruction) + + let plain = DeferredInspectionDebugSession( + capabilities: DebugAdapterCapabilities(negotiated: true, supportsDisassembleRequest: true) + ) + let plainFeature = makeDisassemblyFeature(session: plain, rootPath: "/tmp/lldb-step-granularity-missing") + defer { plainFeature.stop() } + plainFeature.disassembly.present() + // The header warns about this case, so it has to be distinguishable from the supported one. + #expect(plainFeature.disassembly.isPresented) + #expect(!plainFeature.disassembly.canStepByInstruction) + #expect(!plainFeature.disassembly.stepsByInstruction) + } + + @Test + func steppingInsideTheListedRangeOnlyMovesTheCurrentInstructionMarker() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-step-keeps-listing") + defer { feature.stop() } + let rows = (0..<20).map { instruction("0x\(String(0x1000 + $0 * 4, radix: 16))", "nop") } + session.completeDisassembly(at: 0, with: .success(rows)) + let requestsBefore = session.disassemblyReferences.count + + let next = DebugStackFrame( + id: 6, name: "__pthread_kill", sourceURL: nil, line: 0, column: 0, + instructionPointerReference: rows[4].address + ) + feature.disassembly.frameSelected(next, preservingView: true) + + #expect(feature.disassembly.currentAddress == rows[4].address) + #expect(feature.disassembly.rows == rows, "The listing must not blink away for one step") + #expect(feature.disassembly.isCurrent(rows[4])) + #expect(!feature.disassembly.isLoading) + #expect(session.disassemblyReferences.count == requestsBefore) + } + + @Test + func steppingOutOfTheListedRangeReadsTheListingAgain() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-step-leaves-listing") + defer { feature.stop() } + session.completeDisassembly(at: 0, with: .success([instruction("0x1000", "nop"), instruction("0x1004", "ret")])) + + let far = DebugStackFrame( + id: 6, name: "elsewhere", sourceURL: nil, line: 0, column: 0, + instructionPointerReference: "0x9000" + ) + feature.disassembly.frameSelected(far, preservingView: true) + + #expect(feature.disassembly.isLoading) + #expect(session.disassemblyReferences.last == "0x9000") + #expect(session.disassemblyOffsets.last == -GenericDebugDisassemblyModel.instructionsBefore) + } + + @Test + func steppingTowardsTheEndOfTheListingExtendsItBeforeTheMarkerRunsOff() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, _) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-step-extends-listing") + defer { feature.stop() } + let rows = (0..<12).map { instruction("0x\(String(0x2000 + $0 * 4, radix: 16))", "nop") } + session.completeDisassembly(at: 0, with: .success(rows)) + let requestsBefore = session.disassemblyReferences.count + + let near = DebugStackFrame( + id: 6, name: "__pthread_kill", sourceURL: nil, line: 0, column: 0, + instructionPointerReference: rows[8].address + ) + feature.disassembly.frameSelected(near, preservingView: true) + + #expect(feature.disassembly.rows == rows) + #expect(session.disassemblyReferences.count == requestsBefore + 1) + #expect(session.disassemblyReferences.last == rows[11].address) + #expect(session.disassemblyOffsets.last == 1) + } + + // MARK: - Memory refresh while stepping + + @Test + func aStepReadsTheMemoryPageAgainQuietlyAndMarksTheBytesThatChanged() throws { + let session = DeferredInspectionDebugSession(capabilities: Self.disassemblyCapabilities) + let (feature, frame) = makeSourcelessStop(session: session, rootPath: "/tmp/lldb-memory-changed") + defer { feature.stop() } + + // The first read asks for the context before the address; this double refuses it, so the + // model falls back to the page that starts at the address. + feature.disassembly.readMemory(from: "0x1000") + session.completeMemory(at: 0, with: .failure(DeferredDebugSessionError.launchFailed)) + session.completeMemory(at: 1, with: .success(DebugMemoryRead( + address: "0x1000", unreadableBytes: 0, data: Data([1, 2, 3, 4]) + ))) + let revealed = feature.disassembly.memoryRevealToken + #expect(feature.disassembly.memory?.data == Data([1, 2, 3, 4])) + #expect(feature.disassembly.memoryChangedIndices.isEmpty) + + let next = DebugStackFrame( + id: frame.id, name: frame.name, sourceURL: nil, line: 0, column: 0, + instructionPointerReference: "0x19b5f2a50" + ) + feature.disassembly.frameSelected(next, preservingView: true) + // The page on screen is kept while the new bytes are fetched behind it. + #expect(feature.disassembly.memory?.data == Data([1, 2, 3, 4])) + #expect(!feature.disassembly.isLoadingMemory) + session.completeMemory(at: session.memoryOffsets.count - 1, with: .success(DebugMemoryRead( + address: "0x1000", unreadableBytes: 0, data: Data([1, 9, 3, 7]) + ))) + + #expect(feature.disassembly.memory?.data == Data([1, 9, 3, 7])) + #expect(feature.disassembly.memoryChangedIndices == [1, 3]) + #expect(feature.disassembly.memoryRevealToken == revealed, "A refresh must not scroll the page") + + // Resuming makes every address stale, marks included. + feature.disassembly.invalidate() + #expect(feature.disassembly.memory == nil) + #expect(feature.disassembly.memoryChangedIndices.isEmpty) + } + private func workspaceFactory() -> ModuleFactory { ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { EmptyModule(id: .workspace, name: "Workspace") @@ -3427,6 +4704,7 @@ private struct RecordingDebugInspectionRequest: Equatable { let count: Int? let expression: String? let column: Int? + var memory: DebugCoreMemoryQuery? } private struct RecordingDebugVariablePageRequest: Equatable { @@ -3450,6 +4728,8 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi private(set) var launchConfigurations: [DebugLaunchConfiguration] = [] private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] private(set) var executionCommands: [DebugExecutionCommand] = [] + private(set) var steppingGranularities: [DebugSteppingGranularity] = [] + private(set) var instructionBreakpointUpdates: [[DebugInstructionBreakpoint]] = [] var failNextLaunch = false var consoleCommands: [DebugConsoleCommand] = [] var consoleCommandPrefix: String? @@ -3462,6 +4742,7 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi var onEvent: ((DebugAdapterEvent) -> Void)? var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? + private var threadRequests: [(Result<[DebugThread], Error>) -> Void] = [] private var stackTraceRequests: [( threadID: Int, completion: (Result<[DebugStackFrame], Error>) -> Void @@ -3486,7 +4767,23 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi frameID: Int?, completion: (Result) -> Void )] = [] + private var disassemblyRequests: [( + reference: String, + offset: Int, + count: Int, + completion: (Result<[DebugDisassembledInstruction], Error>) -> Void + )] = [] + private var memoryRequests: [( + reference: String, + offset: Int, + count: Int, + completion: (Result) -> Void + )] = [] + var disassemblyReferences: [String] { disassemblyRequests.map(\.reference) } + var disassemblyOffsets: [Int] { disassemblyRequests.map(\.offset) } + var memoryReferences: [String] { memoryRequests.map(\.reference) } + var memoryOffsets: [Int] { memoryRequests.map(\.offset) } var stackTraceThreadIDs: [Int] { stackTraceRequests.map(\.threadID) } var scopeFrameIDs: [Int] { scopeRequests.map(\.frameID) } var variableReferences: [Int] { variableRequests.map(\.reference) } @@ -3546,7 +4843,45 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi func execute(_ command: DebugExecutionCommand, threadID _: Int?) { executionCommands.append(command) } - func requestThreads(_: @escaping (Result<[DebugThread], Error>) -> Void) {} + func execute( + _ command: DebugExecutionCommand, + threadID _: Int?, + granularity: DebugSteppingGranularity + ) { + executionCommands.append(command) + steppingGranularities.append(granularity) + } + func setInstructionBreakpoints(_ breakpoints: [DebugInstructionBreakpoint]) { + instructionBreakpointUpdates.append(breakpoints) + } + func requestDisassembly( + memoryReference: String, + instructionOffset: Int, + instructionCount: Int, + completion: @escaping (Result<[DebugDisassembledInstruction], Error>) -> Void + ) { + disassemblyRequests.append((memoryReference, instructionOffset, instructionCount, completion)) + } + func requestReadMemory( + memoryReference: String, + offset: Int, + count: Int, + completion: @escaping (Result) -> Void + ) { + memoryRequests.append((memoryReference, offset, count, completion)) + } + func completeDisassembly(at index: Int, with result: Result<[DebugDisassembledInstruction], Error>) { + disassemblyRequests[index].completion(result) + } + func completeMemory(at index: Int, with result: Result) { + memoryRequests[index].completion(result) + } + func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) { + threadRequests.append(completion) + } + func completeThreads(at index: Int, with threads: [DebugThread]) { + threadRequests[index](.success(threads)) + } func requestExceptionInfo( threadID: Int, @@ -3615,7 +4950,12 @@ private final class DeferredInspectionDebugSession: DebugAdapterControllingSessi // This double deliberately delivers responses after cancellation to model // adapters and callback queues that cannot retract an already-sent result. - func cancelPendingOperations() {} + var onCancelPendingOperations: (() -> Void)? + func cancelPendingOperations() { + let callback = onCancelPendingOperations + onCancelPendingOperations = nil + callback?() + } func emit(_ event: DebugAdapterEvent) { onEvent?(event) @@ -3656,6 +4996,8 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = [] private(set) var exceptionBreakpointUpdates: [[DebugExceptionBreakpoint]] = [] private(set) var functionBreakpointUpdates: [[DebugFunctionBreakpoint]] = [] + private(set) var instructionBreakpointUpdates: [[DebugInstructionBreakpoint]] = [] + private(set) var lastExecutionGranularity: DebugSteppingGranularity? private(set) var dataBreakpointUpdates: [[DebugDataBreakpoint]] = [] private(set) var cancelledOperationReasons: [String] = [] private(set) var lastExecutionSingleThread: Bool? @@ -3737,6 +5079,14 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { return update(sessionID: sessionID, state: "ready") } + func setDebugInstructionBreakpoints( + sessionID: String, + breakpoints: [DebugInstructionBreakpoint] + ) throws -> DebugCoreUpdate { + instructionBreakpointUpdates.append(breakpoints) + return update(sessionID: sessionID, state: "ready") + } + func debugDataBreakpointInfo( sessionID: String, operationID: String, @@ -3791,10 +5141,12 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { command _: DebugExecutionCommand, threadID: Int?, targetID _: Int?, - singleThread: Bool + singleThread: Bool, + granularity: DebugSteppingGranularity? ) throws -> DebugCoreUpdate { lastExecutionThreadID = threadID lastExecutionSingleThread = singleThread + lastExecutionGranularity = granularity return update(sessionID: sessionID, state: "running") } @@ -3812,7 +5164,8 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { context _: String?, sourcePath _: String?, line _: Int?, - column: Int? + column: Int?, + memory: DebugCoreMemoryQuery? ) throws -> DebugCoreUpdate { lastInspectionOperationID = operationID inspectionRequests.append(RecordingDebugInspectionRequest( @@ -3825,7 +5178,8 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { start: start, count: count, expression: expression, - column: column + column: column, + memory: memory )) return update(sessionID: sessionID, state: "paused") } diff --git a/macos/Tests/LitheTests/DebugJSONSyntaxTests.swift b/macos/Tests/LitheTests/DebugJSONSyntaxTests.swift new file mode 100644 index 0000000000..ce899765ec --- /dev/null +++ b/macos/Tests/LitheTests/DebugJSONSyntaxTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Debug JSON syntax") +struct DebugJSONSyntaxTests { + private func kinds(_ line: String) -> [DebugJSONSyntax.Kind] { + DebugJSONSyntax.tokens(line).filter { $0.kind != .plain }.map(\.kind) + } + + @Test + func keysAndStringValuesGetDifferentKinds() { + #expect(kinds(" \"esr\" : \"0xf2000000\",") == [.key, .punctuation, .string, .punctuation]) + } + + @Test + func scalarValuesAreClassified() { + #expect(kinds("\"a\" : 12,") == [.key, .punctuation, .number, .punctuation]) + #expect(kinds("\"b\" : -1.5e3") == [.key, .punctuation, .number]) + #expect(kinds("\"c\" : true") == [.key, .punctuation, .boolean]) + #expect(kinds("\"d\" : null") == [.key, .punctuation, .null]) + } + + @Test + func foldedLineKeepsTheKeyAndTheClosingPunctuation() { + // key, then `:` `[` `…` `]` `,` + #expect(kinds("\"orders\" : [ … ],") == [.key] + Array(repeating: .punctuation, count: 5)) + } + + @Test + func escapedQuotesAndColonsInsideStringsStayOneString() { + let tokens = DebugJSONSyntax.tokens("\"v\" : \"a \\\" : b\"") + #expect(tokens.map(\.kind) == [.key, .plain, .punctuation, .plain, .string]) + #expect(tokens.last?.text == "\"a \\\" : b\"") + } + + @Test + func tokensRebuildTheOriginalLineExactly() { + for line in ["{", " \"k\" : [", " 1,", "\"x\" : \"unterminated", "plain text, not json", ""] { + #expect(DebugJSONSyntax.tokens(line).map(\.text).joined() == line) + } + } +} diff --git a/rust/lithe-core/src/debug/capabilities.rs b/rust/lithe-core/src/debug/capabilities.rs index b8ec598b69..7e545dc6d3 100644 --- a/rust/lithe-core/src/debug/capabilities.rs +++ b/rust/lithe-core/src/debug/capabilities.rs @@ -32,6 +32,10 @@ pub(crate) const REGISTERED_ADAPTER_CAPABILITIES: &[&str] = &[ "supportsStepInTargetsRequest", "supportsGotoTargetsRequest", "supportsCompletionsRequest", + "supportsDisassembleRequest", + "supportsSteppingGranularity", + "supportsInstructionBreakpoints", + "supportsReadMemoryRequest", ]; /// The session cannot reach `configurationDone` ordering without this capability. @@ -47,13 +51,14 @@ pub(crate) struct HostFeatures { /// Client capabilities for the DAP `initialize` request. /// /// Only extensions registered in this build may be declared; everything else stays -/// `false` so adapters never send requests the Core cannot answer. Memory references and -/// progress reporting have no registered extension yet. +/// `false` so adapters never send requests the Core cannot answer. Memory references are +/// declared because `readMemory` is registered (read-only: `writeMemory` has no extension); +/// progress reporting has no registered extension yet. pub(crate) fn client_capabilities(host: HostFeatures) -> Value { json!({ "supportsRunInTerminalRequest": host.run_in_terminal, "supportsStartDebuggingRequest": host.start_debugging, - "supportsMemoryReferences": false, + "supportsMemoryReferences": true, "supportsProgressReporting": false, "supportsInvalidatedEvent": false }) @@ -134,7 +139,7 @@ mod tests { let adapter = adapter(json!({ "supportsSetVariable": true, "supportsRestartRequest": true, - "supportsReadMemoryRequest": true, + "supportsWriteMemoryRequest": true, "supportsTerminateRequest": false })); let disabled = validate_disabled(&["supportsRestartRequest".to_string()]).unwrap(); @@ -145,9 +150,9 @@ mod tests { assert!(!states["supportsRestartRequest"].authorized); assert!(!states["supportsRestartRequest"].supported); // Adapter offers it but no extension is registered, so the UI never sees it. - assert!(states["supportsReadMemoryRequest"].adapter); - assert!(!states["supportsReadMemoryRequest"].extension); - assert!(!states["supportsReadMemoryRequest"].supported); + assert!(states["supportsWriteMemoryRequest"].adapter); + assert!(!states["supportsWriteMemoryRequest"].extension); + assert!(!states["supportsWriteMemoryRequest"].supported); // Registered but not negotiated. assert!(states["supportsTerminateRequest"].extension); assert!(!states["supportsTerminateRequest"].supported); @@ -159,19 +164,19 @@ mod tests { fn effective_body_clears_unsupported_flags_only() { let adapter = adapter(json!({ "supportsSetVariable": true, - "supportsReadMemoryRequest": true, + "supportsWriteMemoryRequest": true, "exceptionBreakpointFilters": [] })); let states = capability_states(&adapter, &BTreeSet::new()); let body = effective_body(&adapter, &states); assert_eq!(body["supportsSetVariable"], true); - assert_eq!(body["supportsReadMemoryRequest"], false); + assert_eq!(body["supportsWriteMemoryRequest"], false); assert!(body["exceptionBreakpointFilters"].is_array()); } #[test] fn disabling_rejects_unknown_and_mandatory_capabilities() { - assert!(validate_disabled(&["supportsReadMemoryRequest".to_string()]).is_err()); + assert!(validate_disabled(&["supportsWriteMemoryRequest".to_string()]).is_err()); assert!(validate_disabled(&["notACapability".to_string()]).is_err()); assert!(validate_disabled(&["supportsConfigurationDoneRequest".to_string()]).is_err()); } @@ -184,6 +189,6 @@ mod tests { }); assert_eq!(value["supportsRunInTerminalRequest"], true); assert_eq!(value["supportsStartDebuggingRequest"], false); - assert_eq!(value["supportsMemoryReferences"], false); + assert_eq!(value["supportsMemoryReferences"], true); } } diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 5c53bf82ae..eb7057faea 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -43,6 +43,7 @@ struct DebugSession { exception_breakpoints: Vec, did_configure_exception_breakpoints: bool, function_breakpoints: Vec, + instruction_breakpoints: Vec, data_breakpoints: Vec, supports_run_in_terminal_request: bool, pending_run_in_terminal_requests: BTreeMap, @@ -78,6 +79,9 @@ enum PendingRequest { SetFunctionBreakpoints { requested: Vec, }, + SetInstructionBreakpoints { + requested: Vec, + }, DataBreakpointInfo { operation_id: String, }, @@ -153,6 +157,7 @@ pub(crate) fn create_session( exception_breakpoints: Vec::new(), did_configure_exception_breakpoints: false, function_breakpoints: Vec::new(), + instruction_breakpoints: Vec::new(), data_breakpoints: Vec::new(), supports_run_in_terminal_request: request.supports_run_in_terminal_request, pending_run_in_terminal_requests: BTreeMap::new(), @@ -352,6 +357,55 @@ pub(crate) fn set_function_breakpoints( }) } +/// Longest opaque memory or instruction reference the Core forwards to an adapter. +const MAXIMUM_MEMORY_REFERENCE_LENGTH: usize = 256; +/// Largest `readMemory` request, so one call cannot ask an adapter for unbounded bytes. +const MAXIMUM_READ_MEMORY_BYTES: i64 = 65_536; +/// Largest `disassemble` window, enough for a scrolling view without flooding the transport. +const MAXIMUM_DISASSEMBLE_INSTRUCTIONS: i64 = 1_000; + +/// Stores deterministic instruction breakpoints and sends them when supported. +/// +/// References are opaque address strings: they are only trimmed and bounded, never parsed, +/// so a 64-bit address survives untouched and no CPU-specific logic enters the Core. +pub(crate) fn set_instruction_breakpoints( + mut request: SetInstructionBreakpointsRequest, +) -> Result { + for breakpoint in &mut request.breakpoints { + breakpoint.instruction_reference = + validated_memory_reference(&breakpoint.instruction_reference)?; + breakpoint.condition = normalize_optional_text(breakpoint.condition.take()); + breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take()); + } + request.breakpoints.sort_by(|left, right| { + ( + &left.instruction_reference, + left.offset, + left.enabled, + &left.condition, + &left.hit_condition, + ) + .cmp(&( + &right.instruction_reference, + right.offset, + right.enabled, + &right.condition, + &right.hit_condition, + )) + }); + request.breakpoints.dedup_by(|left, right| { + left.instruction_reference == right.instruction_reference && left.offset == right.offset + }); + with_session(&request.session_id, |session| { + session.instruction_breakpoints = request.breakpoints; + if session.did_receive_initialized && session.capabilities.supports_instruction_breakpoints + { + session.send_instruction_breakpoints()?; + } + Ok(session.take_update()) + }) +} + /// Resolves one adapter-owned data breakpoint identity for the selected variable. pub(crate) fn data_breakpoint_info( mut request: DataBreakpointInfoRequest, @@ -619,6 +673,25 @@ pub(crate) fn execute(request: ExecuteRequest) -> Result Result Result Result<(), CoreError> { + let requested: Vec = self + .instruction_breakpoints + .iter() + .filter(|breakpoint| breakpoint.enabled) + .cloned() + .collect(); + let breakpoints: Vec = requested + .iter() + .map(|breakpoint| { + let mut value = Map::new(); + value.insert( + "instructionReference".to_string(), + json!(breakpoint.instruction_reference), + ); + insert_option(&mut value, "offset", breakpoint.offset); + insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref()); + insert_nonempty( + &mut value, + "hitCondition", + breakpoint.hit_condition.as_deref(), + ); + Value::Object(value) + }) + .collect(); + self.send_request( + "setInstructionBreakpoints", + json!({"breakpoints": breakpoints}), + PendingRequest::SetInstructionBreakpoints { requested }, + ) + } + fn send_data_breakpoints(&mut self) -> Result<(), CoreError> { let requested: Vec = self .data_breakpoints @@ -1231,6 +1363,9 @@ impl DebugSession { PendingRequest::SetFunctionBreakpoints { requested } => { self.emit_function_breakpoint_results(&body, &requested) } + PendingRequest::SetInstructionBreakpoints { requested } => { + self.emit_instruction_breakpoint_results(&body, &requested) + } PendingRequest::DataBreakpointInfo { operation_id } => { self.emit(DebugEventBody::OperationCompleted { operation_id, @@ -1308,8 +1443,14 @@ impl DebugSession { self.emit_inspection_projection(kind, thread_id, frame_id, &body); let result = normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path); - // HCR rejection is an operation failure, not a broken transport/session. - if kind == DebugInspectKind::RedefineClasses { + // HCR rejection and unreadable memory are operation failures, not a broken + // transport/session. + if matches!( + kind, + DebugInspectKind::RedefineClasses + | DebugInspectKind::Disassemble + | DebugInspectKind::ReadMemory + ) { match result { Ok(result) => self.emit(DebugEventBody::OperationCompleted { operation_id, @@ -1345,6 +1486,9 @@ impl DebugSession { if self.capabilities.supports_function_breakpoints { self.send_function_breakpoints()?; } + if self.capabilities.supports_instruction_breakpoints { + self.send_instruction_breakpoints()?; + } if self.capabilities.supports_data_breakpoints { self.send_data_breakpoints()?; } @@ -1607,6 +1751,28 @@ impl DebugSession { } } + fn emit_instruction_breakpoint_results( + &mut self, + body: &Value, + requested: &[InstructionBreakpoint], + ) { + let values = body + .get("breakpoints") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for (index, value) in values.iter().enumerate() { + let mut breakpoint = parse_breakpoint(value, None, None, 0); + // Adapters may omit the reference; the request at the same index owns it. + if breakpoint.instruction_reference.is_none() { + breakpoint.instruction_reference = requested + .get(index) + .map(|item| item.instruction_reference.clone()); + } + self.emit(DebugEventBody::Breakpoint { breakpoint }); + } + } + fn emit_data_breakpoint_results(&mut self, body: &Value, requested: &[DataBreakpoint]) { let values = body .get("breakpoints") @@ -1705,6 +1871,7 @@ impl PendingRequest { Self::SetBreakpoints { .. } => "setBreakpoints", Self::SetExceptionBreakpoints => "setExceptionBreakpoints", Self::SetFunctionBreakpoints { .. } => "setFunctionBreakpoints", + Self::SetInstructionBreakpoints { .. } => "setInstructionBreakpoints", Self::DataBreakpointInfo { .. } => "dataBreakpointInfo", Self::SetDataBreakpoints { .. } => "setDataBreakpoints", Self::SetVariable { .. } => "setVariable", @@ -1729,6 +1896,8 @@ impl PendingRequest { | DebugInspectKind::StepInTargets | DebugInspectKind::GotoTargets | DebugInspectKind::Completions + | DebugInspectKind::Disassemble + | DebugInspectKind::ReadMemory ), _ => false, } @@ -1747,13 +1916,31 @@ impl PendingRequest { } fn inspect_arguments(request: &InspectRequest) -> Result, CoreError> { - if request.kind != DebugInspectKind::Variables - && (request.variable_filter.is_some() || request.start.is_some() || request.count.is_some()) + // `readMemory` reuses `count` for its byte length; every other kind rejects paging fields. + let paging_allowed = request.kind == DebugInspectKind::Variables; + let count_allowed = paging_allowed || request.kind == DebugInspectKind::ReadMemory; + if (!paging_allowed && (request.variable_filter.is_some() || request.start.is_some())) + || (!count_allowed && request.count.is_some()) { return Err(invalid_request( "Debug variable paging is only valid for variables inspection.", )); } + let is_memory_kind = matches!( + request.kind, + DebugInspectKind::Disassemble | DebugInspectKind::ReadMemory + ); + if !is_memory_kind + && (request.memory_reference.is_some() + || request.offset.is_some() + || request.instruction_offset.is_some() + || request.instruction_count.is_some() + || request.resolve_symbols.is_some()) + { + return Err(invalid_request( + "Debug memory fields are only valid for disassemble or readMemory.", + )); + } let mut arguments = Map::new(); match request.kind { DebugInspectKind::Threads | DebugInspectKind::RedefineClasses => {} @@ -1845,6 +2032,45 @@ fn inspect_arguments(request: &InspectRequest) -> Result, Cor arguments.insert("column".to_string(), json!(column)); } } + DebugInspectKind::Disassemble => { + let reference = request.memory_reference.as_deref().unwrap_or_default(); + arguments.insert( + "memoryReference".to_string(), + json!(validated_memory_reference(reference)?), + ); + insert_option(&mut arguments, "offset", request.offset); + insert_option( + &mut arguments, + "instructionOffset", + request.instruction_offset, + ); + let count = required_positive(request.instruction_count, "instructionCount")?; + if count > MAXIMUM_DISASSEMBLE_INSTRUCTIONS { + return Err(invalid_request( + "Debug instructionCount cannot exceed 1000 instructions.", + )); + } + arguments.insert("instructionCount".to_string(), json!(count)); + arguments.insert( + "resolveSymbols".to_string(), + json!(request.resolve_symbols.unwrap_or(true)), + ); + } + DebugInspectKind::ReadMemory => { + let reference = request.memory_reference.as_deref().unwrap_or_default(); + arguments.insert( + "memoryReference".to_string(), + json!(validated_memory_reference(reference)?), + ); + insert_option(&mut arguments, "offset", request.offset); + let count = required_positive(request.count, "count")?; + if count > MAXIMUM_READ_MEMORY_BYTES { + return Err(invalid_request( + "Debug readMemory count cannot exceed 65536 bytes.", + )); + } + arguments.insert("count".to_string(), json!(count)); + } DebugInspectKind::Completions => { // Whitespace is significant: `break ` and `break` complete differently. let text = request.expression.as_deref().unwrap_or_default(); @@ -1970,7 +2196,65 @@ fn normalize_inspection( .filter_map(parse_completion_item) .collect(), }), + DebugInspectKind::Disassemble => Ok(DebugOperationResult::Disassemble { + instructions: required_array(body, "instructions")? + .iter() + .filter_map(parse_disassembled_instruction) + .collect(), + }), + DebugInspectKind::ReadMemory => { + // Memory that is entirely unreadable comes back without `data`. + let data_base64 = string_field(body, "data").unwrap_or_default(); + if BASE64.decode(&data_base64).is_err() { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "DAP readMemory data was not valid base64.", + )); + } + Ok(DebugOperationResult::ReadMemory { + address: required_str(body, "address")?.to_string(), + unreadable_bytes: nonnegative_count_field(body, "unreadableBytes"), + data_base64, + }) + } + } +} + +/// Rejects empty, oversized or multi-line references; the value itself stays opaque. +fn validated_memory_reference(reference: &str) -> Result { + let reference = reference.trim(); + if reference.is_empty() + || reference.chars().count() > MAXIMUM_MEMORY_REFERENCE_LENGTH + || reference.chars().any(char::is_control) + { + return Err(invalid_request( + "Debug memory references must be short single-line adapter references.", + )); + } + Ok(reference.to_string()) +} + +fn parse_disassembled_instruction(value: &Value) -> Option { + let address = value.get("address")?.as_str()?; + let instruction = value.get("instruction")?.as_str()?; + if address.is_empty() { + return None; } + Some(DebugDisassembledInstruction { + address: address.to_string(), + instruction_bytes: string_field(value, "instructionBytes").filter(|text| !text.is_empty()), + instruction: instruction.to_string(), + symbol: string_field(value, "symbol").filter(|text| !text.is_empty()), + source_path: value + .get("location") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .map(str::to_string), + line: value + .get("line") + .and_then(Value::as_i64) + .filter(|line| *line > 0), + }) } fn parse_exception_details(value: &Value) -> Option { @@ -2062,6 +2346,9 @@ fn parse_stack_frame( .and_then(Value::as_i64) .filter(|reference| *reference > 0); let presentation_hint = value.get("presentationHint").and_then(Value::as_str); + // A frame in code without debug info has no file; the Core must not invent line 1 of + // nothing, so the UI can show the function and its instruction address instead. + let has_source = source_path.is_some() || source_reference.is_some(); Some(DebugStackFrame { id: value.get("id")?.as_i64()?, is_filtered: stack_frame_matches_filters( @@ -2074,8 +2361,13 @@ fn parse_stack_frame( name, source_path, source_reference, - line: value.get("line").and_then(Value::as_i64).unwrap_or(1), + line: value + .get("line") + .and_then(Value::as_i64) + .unwrap_or(if has_source { 1 } else { 0 }), column: value.get("column").and_then(Value::as_i64).unwrap_or(1), + instruction_pointer_reference: string_field(value, "instructionPointerReference") + .filter(|reference| !reference.is_empty()), }) } @@ -2289,6 +2581,7 @@ fn parse_scope(value: &Value) -> Option { .unwrap_or(false), named_variables: nonnegative_count_field(value, "namedVariables"), indexed_variables: nonnegative_count_field(value, "indexedVariables"), + presentation_hint: string_field(value, "presentationHint").filter(|hint| !hint.is_empty()), }) } @@ -2326,6 +2619,7 @@ fn parse_breakpoint( message: string_field(value, "message"), function_name: function_name.map(str::to_string), data_id: None, + instruction_reference: string_field(value, "instructionReference"), requested_line: None, source_path: value .get("source") @@ -2367,6 +2661,10 @@ fn parse_capabilities(value: &Value) -> DebugCapabilities { supports_step_in_targets_request: bool_field(value, "supportsStepInTargetsRequest"), supports_goto_targets_request: bool_field(value, "supportsGotoTargetsRequest"), supports_completions_request: bool_field(value, "supportsCompletionsRequest"), + supports_disassemble_request: bool_field(value, "supportsDisassembleRequest"), + supports_stepping_granularity: bool_field(value, "supportsSteppingGranularity"), + supports_instruction_breakpoints: bool_field(value, "supportsInstructionBreakpoints"), + supports_read_memory_request: bool_field(value, "supportsReadMemoryRequest"), exception_breakpoint_filters: value .get("exceptionBreakpointFilters") .and_then(Value::as_array) @@ -2940,6 +3238,7 @@ mod tests { line: 12, column: 1, is_filtered: false, + instruction_pointer_reference: None, }], }, }, @@ -3034,6 +3333,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }; let mut negative_start = base.clone(); @@ -3219,6 +3523,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); let stack = receive_messages( @@ -3547,6 +3856,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); assert_eq!( @@ -3907,6 +4221,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); let stack_sequence = 4; @@ -4314,6 +4633,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); assert_eq!(decode_frame(&pending.outbound_frames[0])["seq"], 3); @@ -4354,6 +4678,7 @@ mod tests { thread_id: Some(11), target_id: None, single_thread: true, + granularity: None, }) .unwrap(); let resumed_request = decode_frame(&resumed.outbound_frames[0]); @@ -4433,6 +4758,7 @@ mod tests { thread_id: Some(11), target_id: None, single_thread: false, + granularity: None, }) .unwrap(); let request = decode_frame(&step_back.outbound_frames[0]); @@ -4454,6 +4780,7 @@ mod tests { thread_id: Some(11), target_id: None, single_thread: false, + granularity: None, }) .unwrap(); let request = decode_frame(&restart.outbound_frames[0]); @@ -4468,6 +4795,7 @@ mod tests { thread_id: Some(11), target_id: None, single_thread: false, + granularity: None, }) .unwrap(); let request = decode_frame(&terminate.outbound_frames[0]); @@ -4540,6 +4868,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); let request = decode_frame(&step_targets.outbound_frames[0]); @@ -4575,6 +4908,7 @@ mod tests { thread_id: Some(11), target_id: Some(21), single_thread: false, + granularity: None, }) .unwrap(); assert_eq!( @@ -4607,6 +4941,11 @@ mod tests { source_path: Some("/workspace/src/Main.java".to_string()), line: Some(20), column: Some(5), + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); let request = decode_frame(&goto_targets.outbound_frames[0]); @@ -4639,6 +4978,7 @@ mod tests { thread_id: Some(11), target_id: Some(31), single_thread: false, + granularity: None, }) .unwrap(); let request = decode_frame(&goto.outbound_frames[0]); @@ -4666,6 +5006,11 @@ mod tests { source_path: None, line: None, column, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, } } @@ -4803,6 +5148,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); let request = decode_frame(&inspection.outbound_frames[0]); @@ -4902,6 +5252,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap(); let request = decode_frame(&inspection.outbound_frames[0]); @@ -4992,6 +5347,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, }) .unwrap_err(); assert!(matches!(error.code, ErrorCode::InvalidRequest)); @@ -5198,7 +5558,7 @@ mod tests { vec![response_message( 1, "initialize", - json!({"supportsRestartRequest": true, "supportsReadMemoryRequest": true}), + json!({"supportsRestartRequest": true, "supportsWriteMemoryRequest": true}), )], ); let (revision, states) = snapshot_of(&update).expect("initialize publishes a snapshot"); @@ -5206,7 +5566,7 @@ mod tests { let restart = &states["supportsRestartRequest"]; assert!(restart.adapter && restart.extension && !restart.authorized && !restart.supported); // The adapter reports memory reads, but no extension in this build handles it. - let step_back = &states["supportsReadMemoryRequest"]; + let step_back = &states["supportsWriteMemoryRequest"]; assert!(step_back.adapter && !step_back.extension && !step_back.supported); let late = receive_messages( @@ -5438,6 +5798,11 @@ mod tests { source_path: None, line: None, column: None, + memory_reference: None, + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, } } @@ -5576,6 +5941,7 @@ mod tests { thread_id: Some(11), target_id: None, single_thread: false, + granularity: None, }) .unwrap(); let request_seq = decode_frame(&update.outbound_frames[0])["seq"] @@ -5724,4 +6090,520 @@ mod tests { }) .unwrap(); } + + /// Removes the process-free session even when an assertion fails first. + struct SessionGuard(&'static str); + + impl Drop for SessionGuard { + fn drop(&mut self) { + let _ = destroy_session(SessionRequest { + session_id: self.0.to_string(), + }); + } + } + + fn memory_request( + session_id: &str, + operation_id: &str, + kind: DebugInspectKind, + ) -> InspectRequest { + InspectRequest { + session_id: session_id.to_string(), + operation_id: operation_id.to_string(), + kind, + thread_id: Some(11), + frame_id: Some(7), + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + context: None, + source_path: None, + line: None, + column: None, + memory_reference: Some("0x100003f50".to_string()), + offset: None, + instruction_offset: None, + instruction_count: None, + resolve_symbols: None, + } + } + + fn step_request( + session_id: &str, + command: DebugExecutionCommand, + granularity: Option, + ) -> ExecuteRequest { + ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "step".to_string(), + command, + thread_id: Some(11), + target_id: None, + single_thread: false, + granularity, + } + } + + fn instruction_breakpoint( + reference: &str, + offset: Option, + enabled: bool, + condition: Option<&str>, + ) -> InstructionBreakpoint { + InstructionBreakpoint { + instruction_reference: reference.to_string(), + offset, + enabled, + condition: condition.map(str::to_string), + hit_condition: None, + } + } + + fn request_sequence(update: &DebugSessionUpdate) -> i64 { + decode_frame(&update.outbound_frames[0])["seq"] + .as_i64() + .unwrap() + } + + #[test] + fn sourceless_frames_keep_the_instruction_pointer_without_a_fake_line() { + let filters = DebugSteppingFilters::defaults_for_adapter("lldb"); + let body = json!({"stackFrames": [ + {"id": 1, "name": "main", "line": 9, "column": 1, + "instructionPointerReference": "0x100003f50", + "source": {"name": "main.c", "path": "/workspace/main.c"}}, + {"id": 2, "name": "start", + "instructionPointerReference": "0xffffffffffffffff"}, + {"id": 3, "name": "no-line", + "source": {"name": "a.c", "path": "/workspace/a.c"}}, + {"id": 4, "name": "empty-reference", "instructionPointerReference": ""}, + ]}); + let frames = + normalize_inspection(DebugInspectKind::StackTrace, &body, &filters, "/workspace") + .unwrap(); + let value = serde_json::to_value(frames).unwrap(); + let frames = &value["stackFrames"]; + assert_eq!(frames[0]["line"], 9); + assert_eq!(frames[0]["instructionPointerReference"], "0x100003f50"); + // No file means no line: the UI shows the function and its address instead. + assert_eq!(frames[1]["line"], 0); + assert!(frames[1]["sourcePath"].is_null()); + // A 64-bit address stays an opaque string; it never passes through a JSON number. + assert_eq!( + frames[1]["instructionPointerReference"], + "0xffffffffffffffff" + ); + // A frame that does name a file keeps the historical line-1 fallback. + assert_eq!(frames[2]["line"], 1); + assert!(frames[2].get("instructionPointerReference").is_none()); + assert!(frames[3].get("instructionPointerReference").is_none()); + } + + #[test] + fn registers_scope_keeps_its_presentation_hint() { + let filters = DebugSteppingFilters::defaults_for_adapter("lldb"); + let body = json!({"scopes": [ + {"name": "Locals", "variablesReference": 3}, + {"name": "Registers", "variablesReference": 4, + "presentationHint": "registers", "expensive": true}, + {"name": "Blank", "variablesReference": 5, "presentationHint": ""}, + ]}); + let scopes = + normalize_inspection(DebugInspectKind::Scopes, &body, &filters, "/workspace").unwrap(); + let value = serde_json::to_value(scopes).unwrap(); + let scopes = &value["scopes"]; + assert!(scopes[0].get("presentationHint").is_none()); + assert_eq!(scopes[1]["presentationHint"], "registers"); + assert!(scopes[2].get("presentationHint").is_none()); + } + + #[test] + fn stepping_granularity_is_gated_and_forwarded() { + let unsupported = "debug-granularity-unsupported"; + let _unsupported_guard = SessionGuard(unsupported); + paused_session(unsupported); + let error = execute(step_request( + unsupported, + DebugExecutionCommand::StepIn, + Some(DebugSteppingGranularity::Instruction), + )) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + // The rejected request left the session paused, so a plain step still works. + let update = execute(step_request(unsupported, DebugExecutionCommand::Next, None)).unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "next"); + assert!(request["arguments"].get("granularity").is_none()); + + let supported = "debug-granularity-supported"; + let _supported_guard = SessionGuard(supported); + paused_session_with_capabilities(supported, json!({"supportsSteppingGranularity": true})); + // Granularity only describes how far a step moves. + for command in [ + DebugExecutionCommand::Continue, + DebugExecutionCommand::Pause, + DebugExecutionCommand::Restart, + ] { + assert!(execute(step_request( + supported, + command, + Some(DebugSteppingGranularity::Instruction) + )) + .is_err()); + } + let update = execute(step_request( + supported, + DebugExecutionCommand::StepIn, + Some(DebugSteppingGranularity::Instruction), + )) + .unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "stepIn"); + assert_eq!(request["arguments"]["granularity"], "instruction"); + assert_eq!(request["arguments"]["threadId"], 11); + } + + #[test] + fn instruction_breakpoints_are_validated_deduplicated_and_sent_after_initialized() { + let session_id = "debug-instruction-breakpoints"; + let _guard = SessionGuard(session_id); + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "lldb".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + for invalid in [" ", "0x1000\n0x2000", &"9".repeat(300)] { + assert!( + set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![instruction_breakpoint(invalid, None, true, None)], + }) + .is_err(), + "{invalid:?}" + ); + } + let queued = set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + instruction_breakpoint(" 0x100003f60 ", Some(4), true, None), + instruction_breakpoint(" 0x100003f50 ", None, true, Some(" x > 1 ")), + instruction_breakpoint("0x100003f50", None, true, Some("x > 1")), + instruction_breakpoint("0x100003f70", None, false, None), + ], + }) + .unwrap(); + // The session has not seen `initialized`, so nothing is sent yet. + assert!(queued.outbound_frames.is_empty()); + + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsConfigurationDoneRequest": true, + "supportsInstructionBreakpoints": true + }), + )], + ); + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + let frames: Vec = configured + .outbound_frames + .iter() + .map(|frame| decode_frame(frame)) + .collect(); + let sent = frames + .iter() + .find(|frame| frame["command"] == "setInstructionBreakpoints") + .expect("instruction breakpoints are sent after initialized"); + let breakpoints = sent["arguments"]["breakpoints"].as_array().unwrap(); + // The duplicate address collapsed and the disabled one is not sent. + assert_eq!(breakpoints.len(), 2); + assert_eq!(breakpoints[0]["instructionReference"], "0x100003f50"); + assert_eq!(breakpoints[0]["condition"], "x > 1"); + assert!(breakpoints[0].get("offset").is_none()); + assert_eq!(breakpoints[1]["instructionReference"], "0x100003f60"); + assert_eq!(breakpoints[1]["offset"], 4); + + let results = receive_messages( + session_id, + vec![response_message( + sent["seq"].as_i64().unwrap(), + "setInstructionBreakpoints", + json!({"breakpoints": [ + {"id": 5, "verified": true, "instructionReference": "0x100003f50"}, + {"verified": false, "message": "no code at this address"} + ]}), + )], + ); + let verified: Vec<(Option, bool)> = results + .events + .iter() + .filter_map(|event| match &event.body { + DebugEventBody::Breakpoint { breakpoint } => Some(( + breakpoint.instruction_reference.clone(), + breakpoint.verified, + )), + _ => None, + }) + .collect(); + // The second reply omitted the address, so the request at the same index names it. + assert_eq!( + verified, + vec![ + (Some("0x100003f50".to_string()), true), + (Some("0x100003f60".to_string()), false) + ] + ); + + // Once initialized, a change is sent immediately and an empty list clears the set. + let cleared = set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: Vec::new(), + }) + .unwrap(); + let request = decode_frame(&cleared.outbound_frames[0]); + assert_eq!(request["command"], "setInstructionBreakpoints"); + assert_eq!(request["arguments"]["breakpoints"], json!([])); + } + + #[test] + fn instruction_breakpoints_are_not_sent_without_adapter_support() { + let session_id = "debug-instruction-breakpoints-unsupported"; + let _guard = SessionGuard(session_id); + paused_session(session_id); + let update = set_instruction_breakpoints(SetInstructionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![instruction_breakpoint("0x100003f50", None, true, None)], + }) + .unwrap(); + assert!(update.outbound_frames.is_empty()); + } + + #[test] + fn disassemble_is_gated_bounded_and_normalized() { + let unsupported = "debug-disassemble-unsupported"; + let _unsupported_guard = SessionGuard(unsupported); + paused_session(unsupported); + let mut request = memory_request(unsupported, "dis", DebugInspectKind::Disassemble); + request.instruction_count = Some(4); + assert!(inspect(request).is_err()); + + let session_id = "debug-disassemble"; + let _guard = SessionGuard(session_id); + paused_session_with_capabilities(session_id, json!({"supportsDisassembleRequest": true})); + let valid = |count: Option| { + let mut request = memory_request(session_id, "dis", DebugInspectKind::Disassemble); + request.instruction_count = count; + request + }; + assert!(inspect(valid(None)).is_err()); + assert!(inspect(valid(Some(0))).is_err()); + assert!(inspect(valid(Some(1001))).is_err()); + let mut no_reference = valid(Some(4)); + no_reference.memory_reference = None; + assert!(inspect(no_reference).is_err()); + // Memory fields belong to memory requests only. + let mut misplaced = memory_request(session_id, "threads", DebugInspectKind::Threads); + misplaced.memory_reference = Some("0x100003f50".to_string()); + assert!(inspect(misplaced).is_err()); + + let mut request = valid(Some(5)); + request.instruction_offset = Some(-2); + request.offset = Some(0); + let update = inspect(request).unwrap(); + let sent = decode_frame(&update.outbound_frames[0]); + assert_eq!(sent["command"], "disassemble"); + assert_eq!(sent["arguments"]["memoryReference"], "0x100003f50"); + assert_eq!(sent["arguments"]["instructionOffset"], -2); + assert_eq!(sent["arguments"]["instructionCount"], 5); + assert_eq!(sent["arguments"]["resolveSymbols"], true); + + let completed = receive_messages( + session_id, + vec![response_message( + sent["seq"].as_i64().unwrap(), + "disassemble", + json!({"instructions": [ + {"address": "0x100003f48", "instructionBytes": "fd 7b bf a9", + "instruction": "stp x29, x30, [sp, #-0x10]!", "symbol": "main"}, + {"address": "0xffffffffffffff00", "instruction": "mov x29, sp", + "location": {"path": "/workspace/main.c"}, "line": 9}, + {"instruction": "no address is dropped"}, + {"address": "0x100003f50"} + ]}), + )], + ); + let instructions = completed + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::Disassemble { instructions }, + } if operation_id == "dis" => Some(instructions), + _ => None, + }) + .expect("disassemble result"); + assert_eq!(instructions.len(), 2); + assert_eq!(instructions[0].address, "0x100003f48"); + assert_eq!( + instructions[0].instruction_bytes.as_deref(), + Some("fd 7b bf a9") + ); + assert_eq!(instructions[0].symbol.as_deref(), Some("main")); + assert_eq!(instructions[1].address, "0xffffffffffffff00"); + assert_eq!( + instructions[1].source_path.as_deref(), + Some("/workspace/main.c") + ); + assert_eq!(instructions[1].line, Some(9)); + } + + #[test] + fn disassemble_failure_and_stale_replies_are_operation_failures() { + let session_id = "debug-disassemble-failure"; + let _guard = SessionGuard(session_id); + paused_session_with_capabilities(session_id, json!({"supportsDisassembleRequest": true})); + let mut request = memory_request(session_id, "bad", DebugInspectKind::Disassemble); + request.instruction_count = Some(3); + // A body without `instructions` is a rejected request, not a broken session. + let update = inspect(request.clone()).unwrap(); + let failed = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "disassemble", + json!({}), + )], + ); + assert!(failed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::AdapterRejected, .. } + if operation_id == "bad" + ))); + assert_eq!(session_state(session_id), DebugSessionState::Paused); + + // Addresses belong to one stop: a reply after the next stop is stale. + request.operation_id = "old".to_string(); + let update = inspect(request).unwrap(); + let sequence = request_sequence(&update); + receive_messages(session_id, vec![stopped_event(104)]); + let stale = receive_messages( + session_id, + vec![response_message( + sequence, + "disassemble", + json!({"instructions": [{"address": "0x1", "instruction": "nop"}]}), + )], + ); + assert!(stale.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, code: DebugOperationFailureCode::StaleReference, .. } + if operation_id == "old" + ))); + } + + #[test] + fn read_memory_is_gated_bounded_and_returns_validated_bytes() { + let unsupported = "debug-read-memory-unsupported"; + let _unsupported_guard = SessionGuard(unsupported); + paused_session(unsupported); + let mut request = memory_request(unsupported, "mem", DebugInspectKind::ReadMemory); + request.count = Some(16); + assert!(inspect(request).is_err()); + + let session_id = "debug-read-memory"; + let _guard = SessionGuard(session_id); + paused_session_with_capabilities(session_id, json!({"supportsReadMemoryRequest": true})); + let sized = |count: Option| { + let mut request = memory_request(session_id, "mem", DebugInspectKind::ReadMemory); + request.count = count; + request + }; + assert!(inspect(sized(None)).is_err()); + assert!(inspect(sized(Some(0))).is_err()); + assert!(inspect(sized(Some(65_537))).is_err()); + + let mut request = sized(Some(16)); + request.offset = Some(8); + let update = inspect(request).unwrap(); + let sent = decode_frame(&update.outbound_frames[0]); + assert_eq!(sent["command"], "readMemory"); + assert_eq!(sent["arguments"]["memoryReference"], "0x100003f50"); + assert_eq!(sent["arguments"]["offset"], 8); + assert_eq!(sent["arguments"]["count"], 16); + + let completed = receive_messages( + session_id, + vec![response_message( + sent["seq"].as_i64().unwrap(), + "readMemory", + json!({"address": "0x100003f58", "unreadableBytes": 8, "data": "3q2+7w=="}), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::ReadMemory { address, unreadable_bytes: 8, data_base64 } + } if operation_id == "mem" && address == "0x100003f58" && data_base64 == "3q2+7w==" + ))); + + // Entirely unreadable memory has no `data`; invalid base64 is rejected, not forwarded. + let update = inspect(sized(Some(4))).unwrap(); + let unreadable = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "readMemory", + json!({"address": "0x100003f50", "unreadableBytes": 4}), + )], + ); + assert!(unreadable.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + result: DebugOperationResult::ReadMemory { unreadable_bytes: 4, data_base64, .. }, + .. + } if data_base64.is_empty() + ))); + let update = inspect(sized(Some(4))).unwrap(); + let malformed = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "readMemory", + json!({"address": "0x100003f50", "data": "***not base64***"}), + )], + ); + assert!(malformed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { + code: DebugOperationFailureCode::AdapterRejected, + .. + } + ))); + assert_eq!(session_state(session_id), DebugSessionState::Paused); + } } diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index 696005b06a..cb901c4786 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -249,6 +249,34 @@ pub struct SetFunctionBreakpointsRequest { pub breakpoints: Vec, } +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One breakpoint on a machine instruction. +/// +/// The reference is the opaque address string the adapter reported (a 64-bit address can +/// exceed the JavaScript safe integer range), so the Core never parses or adds to it. +pub struct InstructionBreakpoint { + pub instruction_reference: String, + /// Byte offset from the reference, for adapters that resolve symbol-relative locations. + #[serde(default)] + pub offset: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete instruction breakpoint set for one session. +pub struct SetInstructionBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] /// Resolves an adapter-owned data identifier for one visible variable or expression. @@ -360,6 +388,28 @@ pub struct ExecuteRequest { pub target_id: Option, #[serde(default)] pub single_thread: bool, + /// Step size for `next`, `stepIn`, `stepOut` and `stepBack`; the adapter default when absent. + #[serde(default)] + pub granularity: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// How far one step moves, in the DAP `SteppingGranularity` vocabulary. +pub enum DebugSteppingGranularity { + Statement, + Line, + Instruction, +} + +impl DebugSteppingGranularity { + pub(crate) fn argument(self) -> &'static str { + match self { + Self::Statement => "statement", + Self::Line => "line", + Self::Instruction => "instruction", + } + } } #[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] @@ -378,6 +428,10 @@ pub enum DebugInspectKind { Completions, /// Java Debug Server hot replacement; mutates classes in the debuggee. RedefineClasses, + /// Machine instructions around a memory reference, for the disassembly view. + Disassemble, + /// Raw debuggee memory at a reference. Read-only; writing needs its own authorization. + ReadMemory, } impl DebugInspectKind { @@ -393,6 +447,8 @@ impl DebugInspectKind { Self::GotoTargets => "gotoTargets", Self::Completions => "completions", Self::RedefineClasses => "redefineClasses", + Self::Disassemble => "disassemble", + Self::ReadMemory => "readMemory", } } } @@ -446,6 +502,21 @@ pub struct InspectRequest { pub line: Option, #[serde(default)] pub column: Option, + /// Opaque address or memory reference for `disassemble` and `readMemory`. + #[serde(default)] + pub memory_reference: Option, + /// Byte offset from `memoryReference` (may be negative for `disassemble`). + #[serde(default)] + pub offset: Option, + /// Instruction offset from the resolved start; negative asks for instructions before it. + #[serde(default)] + pub instruction_offset: Option, + /// Number of instructions `disassemble` returns. + #[serde(default)] + pub instruction_count: Option, + /// `disassemble` asks the adapter to resolve symbol names; defaults to true. + #[serde(default)] + pub resolve_symbols: Option, } #[derive(Debug, Clone, Deserialize)] @@ -557,6 +628,10 @@ pub struct DebugCapabilities { pub supports_step_in_targets_request: bool, pub supports_goto_targets_request: bool, pub supports_completions_request: bool, + pub supports_disassemble_request: bool, + pub supports_stepping_granularity: bool, + pub supports_instruction_breakpoints: bool, + pub supports_read_memory_request: bool, pub exception_breakpoint_filters: Vec, } @@ -707,6 +782,29 @@ pub enum DebugOperationResult { Completions { items: Vec, }, + Disassemble { + instructions: Vec, + }, + /// Bytes read from the debuggee, base64 on the wire like DAP's `readMemory`. + ReadMemory { + address: String, + unreadable_bytes: i64, + data_base64: String, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One machine instruction. `address` and `instruction_bytes` stay adapter-formatted +/// strings, so 64-bit addresses are never rounded by a JSON number. +pub struct DebugDisassembledInstruction { + pub address: String, + pub instruction_bytes: Option, + pub instruction: String, + /// Symbol the instruction belongs to, reported only on the first instruction of it. + pub symbol: Option, + pub source_path: Option, + pub line: Option, } #[derive(Debug, Clone, Serialize)] @@ -760,6 +858,9 @@ pub struct DebugBreakpoint { pub function_name: Option, /// Adapter-owned identity for a data-breakpoint verification result. pub data_id: Option, + /// Requested address of an instruction-breakpoint verification result. + #[serde(skip_serializing_if = "Option::is_none")] + pub instruction_reference: Option, pub source_path: Option, /// Original client line, retained when an adapter resolves a different location. #[serde(skip_serializing_if = "Option::is_none")] @@ -788,6 +889,10 @@ pub struct DebugStackFrame { /// on disk, even when it looks like an absolute path. #[serde(skip_serializing_if = "Option::is_none")] pub source_reference: Option, + /// Opaque address of the frame's current instruction. A frame without debug info has no + /// file, so this is what the disassembly view anchors on; `line` is 0 for such a frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub instruction_pointer_reference: Option, pub line: i64, pub column: i64, /// True when the active portable stepping policy matches this frame. @@ -805,6 +910,9 @@ pub struct DebugScope { pub named_variables: i64, /// Adapter-reported count of indexed children, or zero when unavailable. pub indexed_variables: i64, + /// DAP `presentationHint` (`arguments`, `locals`, `registers`), or none. + #[serde(skip_serializing_if = "Option::is_none")] + pub presentation_hint: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index de41103e2b..331f1fba69 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -232,6 +232,8 @@ pub enum CoreCommand { DebugSetExceptionBreakpoints, /// Replaces named function breakpoints for one debug session (`debug.setFunctionBreakpoints`). DebugSetFunctionBreakpoints, + /// Replaces instruction (address) breakpoints for one debug session (`debug.setInstructionBreakpoints`). + DebugSetInstructionBreakpoints, /// Resolves one adapter-owned data breakpoint identity (`debug.dataBreakpointInfo`). DebugDataBreakpointInfo, /// Replaces data breakpoints for one debug session (`debug.setDataBreakpoints`). @@ -587,6 +589,7 @@ impl CoreCommand { "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), + "debug.setInstructionBreakpoints" => Some(Self::DebugSetInstructionBreakpoints), "debug.dataBreakpointInfo" => Some(Self::DebugDataBreakpointInfo), "debug.setDataBreakpoints" => Some(Self::DebugSetDataBreakpoints), "debug.setVariable" => Some(Self::DebugSetVariable), @@ -802,6 +805,7 @@ mod tests { "debug.setBreakpoints", "debug.setExceptionBreakpoints", "debug.setFunctionBreakpoints", + "debug.setInstructionBreakpoints", "debug.dataBreakpointInfo", "debug.setDataBreakpoints", "debug.setVariable", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 595245c85a..c4009d2f9b 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1111,6 +1111,27 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugSetInstructionBreakpoints => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-instruction-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_instruction_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug instruction breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugDataBreakpointInfo => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2e7b2ee49a..4d539bbcb4 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -986,6 +986,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | | `debug.dataBreakpointInfo` | Resolve an adapter-owned data breakpoint identity for a paused variable or field | | `debug.setDataBreakpoints` | Replace and deterministically order one session's resolved data breakpoints | +| `debug.setInstructionBreakpoints` | Replace and deterministically order one session's machine-instruction breakpoints | | `debug.setVariable` | Replace one visible variable value in its adapter-owned parent container | | `debug.cancelOperation` | Cancel or time out one pending operation and ignore its late response | | `debug.execute` | Submit continue, pause, next, step-in, or step-out control | @@ -2180,6 +2181,48 @@ disabled entries, and sends access type, condition, and hit count only when the adapter negotiated data-breakpoint support. Native clients must discard IDs whose `canPersist` is false when the debug session ends. +`debug.setInstructionBreakpoints` replaces one session's instruction (address) +breakpoints. Each entry carries an opaque `instructionReference` (the address +string the adapter reported, never a JSON number, because a 64-bit address can +exceed the JavaScript safe integer range), an optional byte `offset`, `enabled`, +`condition`, and `hitCondition`. Core validates the reference text, sorts and +deduplicates by reference and offset, keeps the set in the session, and sends DAP +`setInstructionBreakpoints` only after the adapter advertised +`supportsInstructionBreakpoints` and sent `initialized`. Clients keep the set per +session and never persist or replay it: an address is only valid for one run of +one process. + +`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 +`supportsSteppingGranularity`; otherwise it omits the field and the adapter steps +by its default unit. Clients use `instruction` while a disassembly view is open. + +`debug.inspect` also accepts the kinds `disassemble` and `readMemory`. Both take +an opaque `memoryReference` and are rejected unless the capability snapshot +reports them supported (`adapter` and registered extension and `authorized`) and +the session is paused. `disassemble` additionally takes `offset` (bytes), +`instructionOffset` (negative asks for instructions before the reference), +`instructionCount`, and `resolveSymbols` (default true), and returns +`instructions` with `address`, `instructionBytes`, `instruction`, an optional +`symbol` on the first instruction of a symbol, and an optional `sourcePath` and +`line`. `readMemory` takes `offset` and `count` and returns `address`, +`unreadableBytes`, and base64 `dataBase64`, like DAP. Core never does address +arithmetic and never opens `writeMemory`: reading is the only memory operation, +and writing would need its own authorization. Both results are tied to the +`stopGeneration` they were requested in, so a late reply after the debuggee +resumed is dropped as stale. + +A stack frame without debug info has no usable file or line. When the adapter +sends neither a source path nor a `sourceReference`, Core reports +`line` 0 and no `sourcePath` for it, and carries the adapter's +`instructionPointerReference` so clients can anchor the disassembly on the frame +instead of showing a made-up file or line 1. A scope whose DAP +`presentationHint` is `registers` is passed through with that hint; the host has +no register table of its own, so register names, groups, and values are the +adapter's. The compatibility cases are in +`shared/fixtures/debug/projection-v2-examples.json`. + `debug.setVariable` accepts the selected variable's parent `variablesReference`, name, and replacement text. Core permits mutation only while paused and after the adapter advertises `supportsSetVariable`, then returns the adapter's diff --git a/shared/fixtures/debug/projection-v2-examples.json b/shared/fixtures/debug/projection-v2-examples.json index 0551a34988..2ccb22e2f7 100644 --- a/shared/fixtures/debug/projection-v2-examples.json +++ b/shared/fixtures/debug/projection-v2-examples.json @@ -69,7 +69,7 @@ } }, { - "name": "capability snapshot without memory extension", + "name": "capability snapshot with read-only memory and no write extension", "value": { "projectionVersion": 2, "sessionID": "s-1", @@ -79,8 +79,9 @@ "revision": 2, "capabilities": { "supportsConfigurationDoneRequest": { "adapter": true, "extension": true, "authorized": true, "supported": true }, - "supportsDisassembleRequest": { "adapter": true, "extension": false, "authorized": true, "supported": false }, - "supportsReadMemoryRequest": { "adapter": true, "extension": false, "authorized": true, "supported": false } + "supportsDisassembleRequest": { "adapter": true, "extension": true, "authorized": true, "supported": true }, + "supportsReadMemoryRequest": { "adapter": true, "extension": true, "authorized": true, "supported": true }, + "supportsWriteMemoryRequest": { "adapter": true, "extension": false, "authorized": true, "supported": false } } } } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 0f56a60b13..2a4510d8c0 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9371,7 +9371,55 @@ "native.openLocalFolder": "Local Folder", "native.size": "Size", "native.thisFolderCannotBeRead": "This folder cannot be read.", - "native.thisFolderIsEmpty": "This folder is empty" + "native.thisFolderIsEmpty": "This folder is empty", + "debugger.disassembly.title": "Disassembly", + "debugger.disassembly.steppingByInstruction": "Stepping by instruction", + "debugger.disassembly.loading": "Loading instructions…", + "debugger.disassembly.selectFrame": "Select a frame to see its instructions", + "debugger.disassembly.loadEarlier": "Load earlier instructions", + "debugger.disassembly.loadLater": "Load later instructions", + "debugger.disassembly.setBreakpoint": "Set Instruction Breakpoint", + "debugger.disassembly.removeBreakpoint": "Remove Instruction Breakpoint", + "debugger.disassembly.toggleBreakpoint": "Toggle instruction breakpoint", + "debugger.disassembly.readMemoryAtAddress": "Read Memory at Address", + "debugger.disassembly.readMemoryAtValue": "Read Memory at Value", + "debugger.disassembly.copyAddress": "Copy Address", + "debugger.disassembly.copyInstruction": "Copy Instruction", + "debugger.disassembly.showDisassembly": "Show Disassembly", + "debugger.memory.title": "Memory", + "debugger.memory.addressOrExpression": "Address or expression", + "debugger.memory.addressOrExpressionLabel": "Memory address or expression", + "debugger.memory.previousPage": "Previous page", + "debugger.memory.nextPage": "Next page", + "debugger.memory.reading": "Reading memory…", + "debugger.memory.enterAddress": "Enter an address, or a register or variable that holds one", + "debugger.memory.notAnAddress": "The expression does not hold an address", + "debugger.memory.unreadableBytes": "{arg1} bytes could not be read", + "debugger.registers.title": "Registers", + "debugger.memory.displayOptions": "Memory display options", + "debugger.memory.numberBase": "Number Base", + "debugger.memory.valueSize": "Value Size", + "debugger.memory.bytesPerRow": "Bytes per Row", + "debugger.memory.byteOrder": "Byte Order", + "debugger.memory.littleEndian": "Little Endian", + "debugger.memory.bigEndian": "Big Endian", + "debugger.memory.radix.hexadecimal": "Hexadecimal", + "debugger.memory.radix.unsignedDecimal": "Unsigned Decimal", + "debugger.memory.radix.signedDecimal": "Signed Decimal", + "debugger.memory.radix.octal": "Octal", + "debugger.memory.radix.binary": "Binary", + "debugger.memory.radix.floatingPoint": "Floating Point", + "debugger.memory.size.one": "1 byte", + "debugger.memory.size.two": "2 bytes", + "debugger.memory.size.four": "4 bytes", + "debugger.memory.size.eight": "8 bytes", + "debugger.memory.showTextColumn": "Show Text Column", + "debugger.memory.resetDisplay": "Reset Display Options", + "debugger.memory.offsetsRelativeTo": "Offsets relative to {arg1}", + "debugger.memory.changedBytes": "{arg1} bytes changed", + "debugger.step.overInstruction": "Step Over Instruction", + "debugger.step.intoInstruction": "Step Into Instruction", + "debugger.disassembly.stepsByLine": "This adapter cannot step by instruction; steps still move by line" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index e9db5ee591..139e435d57 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9371,7 +9371,55 @@ "native.openLocalFolder": "本地文件夹", "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", - "native.thisFolderIsEmpty": "此文件夹为空" + "native.thisFolderIsEmpty": "此文件夹为空", + "debugger.disassembly.title": "反汇编", + "debugger.disassembly.steppingByInstruction": "按指令单步", + "debugger.disassembly.loading": "正在加载指令…", + "debugger.disassembly.selectFrame": "选择一个栈帧以查看其指令", + "debugger.disassembly.loadEarlier": "加载更早的指令", + "debugger.disassembly.loadLater": "加载更晚的指令", + "debugger.disassembly.setBreakpoint": "设置指令断点", + "debugger.disassembly.removeBreakpoint": "移除指令断点", + "debugger.disassembly.toggleBreakpoint": "切换指令断点", + "debugger.disassembly.readMemoryAtAddress": "读取此地址的内存", + "debugger.disassembly.readMemoryAtValue": "读取此值指向的内存", + "debugger.disassembly.copyAddress": "复制地址", + "debugger.disassembly.copyInstruction": "复制指令", + "debugger.disassembly.showDisassembly": "显示反汇编", + "debugger.memory.title": "内存", + "debugger.memory.addressOrExpression": "地址或表达式", + "debugger.memory.addressOrExpressionLabel": "内存地址或表达式", + "debugger.memory.previousPage": "上一页", + "debugger.memory.nextPage": "下一页", + "debugger.memory.reading": "正在读取内存…", + "debugger.memory.enterAddress": "输入地址,或保存着地址的寄存器或变量", + "debugger.memory.notAnAddress": "该表达式的值不是地址", + "debugger.memory.unreadableBytes": "有 {arg1} 字节无法读取", + "debugger.registers.title": "寄存器", + "debugger.memory.displayOptions": "内存显示选项", + "debugger.memory.numberBase": "数制", + "debugger.memory.valueSize": "值宽度", + "debugger.memory.bytesPerRow": "每行字节数", + "debugger.memory.byteOrder": "字节序", + "debugger.memory.littleEndian": "小端序", + "debugger.memory.bigEndian": "大端序", + "debugger.memory.radix.hexadecimal": "十六进制", + "debugger.memory.radix.unsignedDecimal": "无符号十进制", + "debugger.memory.radix.signedDecimal": "有符号十进制", + "debugger.memory.radix.octal": "八进制", + "debugger.memory.radix.binary": "二进制", + "debugger.memory.radix.floatingPoint": "浮点数", + "debugger.memory.size.one": "1 字节", + "debugger.memory.size.two": "2 字节", + "debugger.memory.size.four": "4 字节", + "debugger.memory.size.eight": "8 字节", + "debugger.memory.showTextColumn": "显示文本列", + "debugger.memory.resetDisplay": "恢复默认显示", + "debugger.memory.offsetsRelativeTo": "偏移相对于 {arg1}", + "debugger.memory.changedBytes": "{arg1} 字节已变化", + "debugger.step.overInstruction": "按指令单步跳过", + "debugger.step.intoInstruction": "按指令单步进入", + "debugger.disassembly.stepsByLine": "该适配器不支持按指令单步,单步仍按源码行前进" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 30a9695c2c..a63fbe0068 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89187,6 +89187,549 @@ "parameters": { "arg1": "string" } + }, + { + "id": "debugger.disassembly.title", + "description": "Debug panel tab that shows machine instructions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Disassembly", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.steppingByInstruction", + "description": "Badge shown while Step Over / Step Into move one machine instruction at a time", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stepping by instruction", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.loading", + "description": "Disassembly placeholder while instructions are being requested", + "platforms": [ + "macos" + ], + "macos": { + "key": "Loading instructions…", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.selectFrame", + "description": "Disassembly placeholder when no frame is selected", + "platforms": [ + "macos" + ], + "macos": { + "key": "Select a frame to see its instructions", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.loadEarlier", + "description": "Disassembly button that requests instructions before the first row", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load earlier instructions", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.loadLater", + "description": "Disassembly button that requests instructions after the last row", + "platforms": [ + "macos" + ], + "macos": { + "key": "Load later instructions", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.setBreakpoint", + "description": "Disassembly context menu: set a breakpoint on an instruction address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Set Instruction Breakpoint", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.removeBreakpoint", + "description": "Disassembly context menu: remove the breakpoint on an instruction address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remove Instruction Breakpoint", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.toggleBreakpoint", + "description": "Tooltip and accessibility label of the disassembly breakpoint gutter", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toggle instruction breakpoint", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.readMemoryAtAddress", + "description": "Disassembly context menu: show memory starting at the instruction address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read Memory at Address", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.readMemoryAtValue", + "description": "Variable context menu: show memory starting at the address held by the value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read Memory at Value", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.copyAddress", + "description": "Context menu: copy an instruction or frame address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Address", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.copyInstruction", + "description": "Disassembly context menu: copy the instruction text", + "platforms": [ + "macos" + ], + "macos": { + "key": "Copy Instruction", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.showDisassembly", + "description": "Stack frame context menu: open the disassembly for this frame", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Disassembly", + "arguments": [] + } + }, + { + "id": "debugger.memory.title", + "description": "Debug panel tab that shows a memory dump", + "platforms": [ + "macos" + ], + "macos": { + "key": "Memory", + "arguments": [] + } + }, + { + "id": "debugger.memory.addressOrExpression", + "description": "Placeholder of the memory address field", + "platforms": [ + "macos" + ], + "macos": { + "key": "Address or expression", + "arguments": [] + } + }, + { + "id": "debugger.memory.addressOrExpressionLabel", + "description": "Accessibility label of the memory address field", + "platforms": [ + "macos" + ], + "macos": { + "key": "Memory address or expression", + "arguments": [] + } + }, + { + "id": "debugger.memory.previousPage", + "description": "Memory dump: show the previous page of bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Previous page", + "arguments": [] + } + }, + { + "id": "debugger.memory.nextPage", + "description": "Memory dump: show the next page of bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next page", + "arguments": [] + } + }, + { + "id": "debugger.memory.reading", + "description": "Memory placeholder while bytes are being requested", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading memory…", + "arguments": [] + } + }, + { + "id": "debugger.memory.enterAddress", + "description": "Memory placeholder before any address was entered", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter an address, or a register or variable that holds one", + "arguments": [] + } + }, + { + "id": "debugger.memory.notAnAddress", + "description": "Memory error when the entered expression does not evaluate to an address", + "platforms": [ + "macos" + ], + "macos": { + "key": "The expression does not hold an address", + "arguments": [] + } + }, + { + "id": "debugger.memory.unreadableBytes", + "description": "Memory dump note: the adapter could not read the trailing bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld bytes could not be read", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "debugger.registers.title", + "description": "Debug panel tab that shows the register scope", + "platforms": [ + "macos" + ], + "macos": { + "key": "Registers", + "arguments": [] + } + }, + { + "id": "debugger.memory.displayOptions", + "description": "Tooltip and accessibility label of the memory view display options menu", + "platforms": [ + "macos" + ], + "macos": { + "key": "Memory display options", + "arguments": [] + } + }, + { + "id": "debugger.memory.numberBase", + "description": "Memory display options submenu: the number base values are shown in", + "platforms": [ + "macos" + ], + "macos": { + "key": "Number Base", + "arguments": [] + } + }, + { + "id": "debugger.memory.valueSize", + "description": "Memory display options submenu: how many bytes make one value", + "platforms": [ + "macos" + ], + "macos": { + "key": "Value Size", + "arguments": [] + } + }, + { + "id": "debugger.memory.bytesPerRow", + "description": "Memory display options submenu: how many bytes one row holds", + "platforms": [ + "macos" + ], + "macos": { + "key": "Bytes per Row", + "arguments": [] + } + }, + { + "id": "debugger.memory.byteOrder", + "description": "Memory display options submenu: which byte of a value comes first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Byte Order", + "arguments": [] + } + }, + { + "id": "debugger.memory.littleEndian", + "description": "Memory display options: least significant byte first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Little Endian", + "arguments": [] + } + }, + { + "id": "debugger.memory.bigEndian", + "description": "Memory display options: most significant byte first", + "platforms": [ + "macos" + ], + "macos": { + "key": "Big Endian", + "arguments": [] + } + }, + { + "id": "debugger.memory.radix.hexadecimal", + "description": "Memory display options: show values in base 16", + "platforms": [ + "macos" + ], + "macos": { + "key": "Hexadecimal", + "arguments": [] + } + }, + { + "id": "debugger.memory.radix.unsignedDecimal", + "description": "Memory display options: show values as unsigned base 10 integers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Unsigned Decimal", + "arguments": [] + } + }, + { + "id": "debugger.memory.radix.signedDecimal", + "description": "Memory display options: show values as signed base 10 integers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Signed Decimal", + "arguments": [] + } + }, + { + "id": "debugger.memory.radix.octal", + "description": "Memory display options: show values in base 8", + "platforms": [ + "macos" + ], + "macos": { + "key": "Octal", + "arguments": [] + } + }, + { + "id": "debugger.memory.radix.binary", + "description": "Memory display options: show values in base 2", + "platforms": [ + "macos" + ], + "macos": { + "key": "Binary", + "arguments": [] + } + }, + { + "id": "debugger.memory.radix.floatingPoint", + "description": "Memory display options: show 4 or 8 byte values as IEEE floating point numbers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Floating Point", + "arguments": [] + } + }, + { + "id": "debugger.memory.size.one", + "description": "Memory display options: one value is a single byte", + "platforms": [ + "macos" + ], + "macos": { + "key": "1 byte", + "arguments": [] + } + }, + { + "id": "debugger.memory.size.two", + "description": "Memory display options: one value is 2 bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "2 bytes", + "arguments": [] + } + }, + { + "id": "debugger.memory.size.four", + "description": "Memory display options: one value is 4 bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "4 bytes", + "arguments": [] + } + }, + { + "id": "debugger.memory.size.eight", + "description": "Memory display options: one value is 8 bytes", + "platforms": [ + "macos" + ], + "macos": { + "key": "8 bytes", + "arguments": [] + } + }, + { + "id": "debugger.memory.showTextColumn", + "description": "Memory display options: toggle the printable character column", + "platforms": [ + "macos" + ], + "macos": { + "key": "Show Text Column", + "arguments": [] + } + }, + { + "id": "debugger.memory.resetDisplay", + "description": "Memory display options: go back to hexadecimal bytes, 16 to a row", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reset Display Options", + "arguments": [] + } + }, + { + "id": "debugger.memory.offsetsRelativeTo", + "description": "Memory view strip: the address that the row offsets are counted from", + "platforms": [ + "macos" + ], + "macos": { + "key": "Offsets relative to %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "debugger.memory.changedBytes", + "description": "Memory view strip: bytes that differ from before the last step", + "platforms": [ + "macos" + ], + "macos": { + "key": "%lld bytes changed", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "integer" + } + }, + { + "id": "debugger.step.overInstruction", + "description": "Toolbar tooltip for step over while the disassembly is open and the adapter steps by instruction", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step Over Instruction", + "arguments": [] + } + }, + { + "id": "debugger.step.intoInstruction", + "description": "Toolbar tooltip for step into while the disassembly is open and the adapter steps by instruction", + "platforms": [ + "macos" + ], + "macos": { + "key": "Step Into Instruction", + "arguments": [] + } + }, + { + "id": "debugger.disassembly.stepsByLine", + "description": "Disassembly header note: the adapter cannot step by instruction, so stepping still moves by source line", + "platforms": [ + "macos" + ], + "macos": { + "key": "This adapter cannot step by instruction; steps still move by line", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/debug-disassembly.json b/shared/platform-feature-matrix/features/debug-disassembly.json new file mode 100644 index 0000000000..3e16338f73 --- /dev/null +++ b/shared/platform-feature-matrix/features/debug-disassembly.json @@ -0,0 +1,42 @@ +{ + "id": "debug-disassembly", + "area": "运行与调试", + "group": "调试器", + "capability": "汇编级调试:无源码栈帧、反汇编视图、指令步进、指令断点、寄存器与只读内存,均由适配器能力决定是否可用", + "macos": { + "evidence": [ + "rust/lithe-core/src/debug/types.rs", + "rust/lithe-core/src/debug/capabilities.rs", + "rust/lithe-core/src/debug/engine.rs", + "rust/lithe-core/src/protocol/command.rs", + "rust/lithe-core/src/runtime/dispatcher.rs", + "macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift", + "macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift", + "macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift", + "macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift", + "macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift", + "macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift", + "macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift", + "macos/Sources/LitheDebugModule/Application/GenericDebugMemoryDisplay.swift", + "macos/Sources/Lithe/Views/Debug/GenericDebugView.swift", + "macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift", + "macos/Sources/Lithe/Views/Debug/DebugVariableJSONView.swift", + "macos/Sources/Lithe/Views/Debug/DebugJSONSyntax.swift", + "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "macos/Tests/LitheTests/DebugJSONSyntaxTests.swift", + "shared/fixtures/debug/projection-v2-examples.json" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/debug/engine.rs", + "rust/lithe-core/src/debug/capabilities.rs" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Debug", + "verification": "macOS:用只带符号表、没有调试信息的 C 程序(含 raise(SIGTRAP))经 Xcode lldb-dap 启动调试,暂停后栈帧显示函数名与地址而不是伪造的文件和第 1 行,反汇编标签自动打开并高亮当前指令;适配器不声明 supportsDisassembleRequest 时没有反汇编标签。反汇编中单步改为按指令前进(仅适配器声明 supportsSteppingGranularity 时),关闭反汇编标签后恢复按行单步;点击地址栏设置或移除指令断点(仅适配器声明 supportsInstructionBreakpoints 时),断点只属于当前会话、不写入持久化,新会话不重放。反汇编打开时,步过/步入的按钮提示变为按指令单步,头部显示“按指令单步”;适配器不支持时头部提示单步仍按源码行前进。寄存器作用域出现在寄存器标签,内存标签接受地址或保存着地址的表达式,从目标地址前 64 字节读起并分页,目标字节在十六进制和 ASCII 两侧高亮并滚入可见区,读取不到的字节显示为缺失而不是 00;右侧显示设置可切换进制、每格字节数、每行字节数、字节序与 ASCII 列;只读内存,不开放 writeMemory。连续单步时线程、调用栈、变量、反汇编、内存与 JSON 视图原地更新,不整面板清空重画,变化的内存字节被标出,继续执行或会话结束才清空。单步暂停到新作用域返回之前,旧变量树与 JSON 仍不可操作;同步取消和切换会话后的旧反汇编失败不重试、不覆盖保留的内存;传输失败清空保留数据。内存偏移表头使用请求引用而不是返回的页首地址。地址和引用对宿主是不透明字符串,宿主不做地址运算、不内置 CPU 寄存器表,也不按适配器 ID 分支。Windows 尚未实现,共享 Rust 内核已经提供能力快照与 debug.inspect 的 disassemble、readMemory,待 React 界面接入。" +} diff --git a/shared/proposals/debug-adapters-1210/projection-v2-examples.draft.json b/shared/proposals/debug-adapters-1210/projection-v2-examples.draft.json index 0551a34988..2ccb22e2f7 100644 --- a/shared/proposals/debug-adapters-1210/projection-v2-examples.draft.json +++ b/shared/proposals/debug-adapters-1210/projection-v2-examples.draft.json @@ -69,7 +69,7 @@ } }, { - "name": "capability snapshot without memory extension", + "name": "capability snapshot with read-only memory and no write extension", "value": { "projectionVersion": 2, "sessionID": "s-1", @@ -79,8 +79,9 @@ "revision": 2, "capabilities": { "supportsConfigurationDoneRequest": { "adapter": true, "extension": true, "authorized": true, "supported": true }, - "supportsDisassembleRequest": { "adapter": true, "extension": false, "authorized": true, "supported": false }, - "supportsReadMemoryRequest": { "adapter": true, "extension": false, "authorized": true, "supported": false } + "supportsDisassembleRequest": { "adapter": true, "extension": true, "authorized": true, "supported": true }, + "supportsReadMemoryRequest": { "adapter": true, "extension": true, "authorized": true, "supported": true }, + "supportsWriteMemoryRequest": { "adapter": true, "extension": false, "authorized": true, "supported": false } } } }