From 604a6c658900040a1e12c69aa9af3872e4866a5c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 14:49:43 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(debug):=20#1210=20=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E6=B1=87=E7=BC=96=E7=BA=A7=E8=B0=83=E8=AF=95=EF=BC=9A=E5=8F=8D?= =?UTF-8?q?=E6=B1=87=E7=BC=96=E3=80=81=E6=8C=87=E4=BB=A4=E6=AD=A5=E8=BF=9B?= =?UTF-8?q?=E3=80=81=E6=8C=87=E4=BB=A4=E6=96=AD=E7=82=B9=E3=80=81=E5=AF=84?= =?UTF-8?q?=E5=AD=98=E5=99=A8=E4=B8=8E=E5=8F=AA=E8=AF=BB=E5=86=85=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内核按适配器能力开放汇编级调试,插件只负责启动 lldb-dap/gdb 等适配器;能力快照 supported = 适配器声明 ∧ 已注册扩展 ∧ 已授权。 - 无源码栈帧:栈帧不伪造文件和第 1 行,显示函数名与地址,暂停后自动打开反汇编 - 反汇编:debug.inspect disassemble 与反汇编视图,高亮当前指令,保持最小滚动,剩余不足时向后续读 - 指令步进:debug.execute 增加 granularity,仅适配器声明 supportsSteppingGranularity 时生效;反汇编打开时步过/步入按指令前进,面板头部与按钮提示同步变化,不支持时提示仍按源码行前进 - 指令断点:debug.setInstructionBreakpoints,点击地址栏设置或移除;只属于当前会话,不持久化、不重放 - 寄存器:作用域 presentationHint registers 显示为寄存器标签;宿主不内置 CPU 寄存器表 - 内存:debug.inspect readMemory,接受地址或保存着地址的表达式,从目标前 64 字节读起并分页;目标字节在十六进制和 ASCII 两侧高亮并滚入可见区;右侧显示设置可切换进制、每格字节数、每行字节数、字节序与 ASCII 列;读不到的字节显示为缺失;只读,不开放 writeMemory - 单步局部刷新:next/stepIn/stepOut/stepBack 保留现有检查数据并原地替换,线程、调用栈、变量、反汇编、内存与 JSON 视图不再整面板清空,变化的内存字节被标出;继续、暂停、结束才清空 - 变量 JSON 视图:修复顶部空白带,key/value 分色高亮 - i18n:新增词条走 shared/i18n 并生成;功能矩阵新增 debug-disassembly(Windows 待实现);设计笔记补充真实 lldb-dap 验证结论 --- .../2026-10-25-optional-debug-extensions.md | 28 +- ...ified-debug-engine-and-adapter-registry.md | 2 + macos/Resources/en.lproj/Localizable.strings | 96 ++ .../zh-Hans.lproj/Localizable.strings | 96 ++ .../Core/Rust/RustDebugProtocolCore.swift | 47 +- .../MacOS/MonacoWorkbenchEditor.swift | 2 + .../Lithe/Views/Debug/DebugJSONSyntax.swift | 101 ++ .../Views/Debug/DebugVariableJSONView.swift | 55 +- .../Debug/GenericDebugDisassemblyView.swift | 684 +++++++++ .../Lithe/Views/Debug/GenericDebugView.swift | 195 ++- .../Views/Diff/DiffSyntaxHighlighter.swift | 2 +- .../Debug/DebugAdapterContracts.swift | 157 +- .../Debug/DebugProtocolCore.swift | 125 +- .../GenericDebugDisassemblyModel.swift | 568 ++++++++ .../GenericDebugFeatureModel.swift | 349 ++++- .../GenericDebugMemoryDisplay.swift | 200 +++ .../CoreDebugAdapterProtocolSession.swift | 130 +- .../Runtime/DebugAdapterSessionManager.swift | 10 + .../DebugModuleTests.swift | 1269 ++++++++++++++++- .../LitheTests/DebugJSONSyntaxTests.swift | 43 + rust/lithe-core/src/debug/capabilities.rs | 27 +- rust/lithe-core/src/debug/engine.rs | 896 +++++++++++- rust/lithe-core/src/debug/types.rs | 108 ++ rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 21 + shared/contracts/rust-core-api.md | 43 + .../debug/projection-v2-examples.json | 7 +- shared/i18n/locales/en-US.json | 50 +- shared/i18n/locales/zh-CN.json | 50 +- shared/i18n/messages.json | 543 +++++++ .../features/debug-disassembly.json | 42 + .../projection-v2-examples.draft.json | 7 +- 32 files changed, 5833 insertions(+), 124 deletions(-) create mode 100644 macos/Sources/Lithe/Views/Debug/DebugJSONSyntax.swift create mode 100644 macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift create mode 100644 macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift create mode 100644 macos/Sources/LitheDebugModule/Application/GenericDebugMemoryDisplay.swift create mode 100644 macos/Tests/LitheTests/DebugJSONSyntaxTests.swift create mode 100644 shared/platform-feature-matrix/features/debug-disassembly.json 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..1507f8fcca 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,32 @@ 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 行)时向后补页。 +- **仍然关闭**:`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..aab5b9ff63 --- /dev/null +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift @@ -0,0 +1,684 @@ +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 { + NSFont(name: "JetBrainsMono-Regular", size: Self.fontSize)?.maximumAdvancement.width + ?? Self.fontSize * 0.6 + } + + 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) { + TextField("Address or expression", text: $input) + .textFieldStyle(.plain) + .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 address 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.address)") + .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 { + NSFont(name: "JetBrainsMono-Regular", size: 12)?.maximumAdvancement.width ?? 7.2 + } + + /// `+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..18ac3ee691 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,7 +507,12 @@ 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, @@ -462,13 +520,96 @@ struct GenericDebugView: View { onCommit: { inspectorWidth = $0 }) { executionInspector } flexible: { - dataInspector + data } } .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 +677,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 +694,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 +977,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 +1106,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 +2101,7 @@ struct DebugBreakpointManagerView: View { private enum DebugContent: CaseIterable, Identifiable { case debugger + case disassembly case console var id: Self { self } @@ -1935,6 +2109,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..f940694fd2 --- /dev/null +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift @@ -0,0 +1,568 @@ +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 `address` +/// 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() { + disassemblyGeneration &+= 1 + memoryGeneration &+= 1 + currentAddress = nil + rows = [] + isLoading = false + loadFailure = nil + memory = nil + memoryChangedIndices = [] + memoryFailure = nil + 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, + 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, + 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, + 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 + switch result { + case .success(let instructions): + onSuccess(instructions) + case .failure(let error): + if retriesForward, offset < 0, let self { + self.request( + session: session, + 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..df59197dbd 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,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self.isExecutionRequestPending = false } if state == .running { - self.clearStoppedInspection() + self.suspendOrClearStoppedInspection() } if state == .terminated || state == .failed { + self.retainsInspectionForStep = false + self.staleVariables = nil self.outputPresentation.flush() } self.saveActiveSessionSnapshot() @@ -532,7 +587,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 +724,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 +1307,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 +1360,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 +1387,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 +1415,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 +1439,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedThreadID = thread.id selectedFrameID = nil selectedFrame = nil + disassembly.invalidate() stackFrames = [] areFilteredStackFramesExpanded = false scopes = [] @@ -1405,15 +1473,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 +1505,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 +1524,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 +1532,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu namedVariables: scope.namedVariables, indexedVariables: scope.indexedVariables, frameID: frameID, - generation: generation + generation: generation, + preserving: preserving ) } @@ -1482,8 +1567,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 +1640,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 +1658,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 +1682,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 +1706,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 +1731,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } public func loadMoreVariables(parentVariableID: String?) { + guard !isVariableTreeBusy else { return } requestVariablePage( parentVariableID: parentVariableID, frameID: selectedFrameID, @@ -1634,23 +1741,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 +2009,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu selectedFrameID = nil stoppedFrame = nil selectedFrame = nil + disassembly.invalidate() threads = [] stoppedThreadIDs = [] stackFrames = [] @@ -1910,10 +2022,24 @@ 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 + } + // 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( @@ -2086,6 +2212,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu outputPresentation.flush() isExecutionRequestPending = false let generation = beginInspectionTransition() + // 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 let threadID { stoppedThreadIDs.insert(threadID) } else { @@ -2093,23 +2222,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 +2253,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 +2291,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 +2315,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 +2350,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 @@ -2562,7 +2727,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 +2737,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 +2844,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 +2876,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } else { variablePageStates[pageID] = state } + return additions } private func variablePageID(_ parentVariableID: String?) -> String { @@ -2641,6 +2884,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 +2904,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu automaticExpressionOrder = [] automaticExpressionResults = [:] automaticExpressionFrameID = nil + automaticResultsArePreviousStop = false } private func replaceVariable(_ replacement: DebugVariable) { @@ -2694,7 +2945,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 +2953,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 +2977,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..0926365121 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -3188,6 +3188,1195 @@ 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 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 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 +4616,7 @@ private struct RecordingDebugInspectionRequest: Equatable { let count: Int? let expression: String? let column: Int? + var memory: DebugCoreMemoryQuery? } private struct RecordingDebugVariablePageRequest: Equatable { @@ -3450,6 +4640,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 +4654,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 +4679,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 +4755,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, @@ -3656,6 +4903,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 +4986,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 +5048,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 +5071,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 +5085,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..88e591858c --- /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 视图原地更新,不整面板清空重画,变化的内存字节被标出,继续执行或会话结束才清空。地址和引用对宿主是不透明字符串,宿主不做地址运算、不内置 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 } } } } From a89477e1297cc8b826414862fd4bc5e58026394c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 20:23:22 +0800 Subject: [PATCH 2/2] fix(debug): #1210 guard retained inspection across steps and cancellation Freeze the old variable tree before the next stop reloads scopes and keep inspection controls read-only until replacement. Retire disassembly and memory callbacks before synchronous cancellation, and reject stale disassembly retries after session changes. Clear retained inspection when transport fails or the session terminates. Label memory offsets relative to the requested reference, use shared text-input and font owners, and update the owning decision note and platform verification steps. Add four deterministic regressions reproduced against the original PR. Validation: 152 selected Swift tests and 72 Rust debug tests passed; i18n, shared contracts, platform boundaries, test stability, Agent Notes, matrix gates and runtime bundle immutability passed. Real GUI and Windows runtime validation remain pending. --- .../2026-10-25-optional-debug-extensions.md | 1 + .../Debug/GenericDebugDisassemblyView.swift | 12 +-- .../Lithe/Views/Debug/GenericDebugView.swift | 4 + .../GenericDebugDisassemblyModel.swift | 21 +++- .../GenericDebugFeatureModel.swift | 10 +- .../DebugModuleTests.swift | 95 ++++++++++++++++++- .../features/debug-disassembly.json | 2 +- 7 files changed, 129 insertions(+), 16 deletions(-) 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 1507f8fcca..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 @@ -74,6 +74,7 @@ GDB、LLDB 可以调试 C、C++、Rust 和汇编,但二进制不一定有源 - **内存**:输入框接受 `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`) diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift index aab5b9ff63..2b92ea14cd 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugDisassemblyView.swift @@ -68,8 +68,7 @@ struct GenericDebugDisassemblyView: View { } private var characterWidth: CGFloat { - NSFont(name: "JetBrainsMono-Regular", size: Self.fontSize)?.maximumAdvancement.width - ?? Self.fontSize * 0.6 + LitheTheme.editorFont(size: Self.fontSize).maximumAdvancement.width } var body: some View { @@ -329,8 +328,7 @@ struct GenericDebugMemoryView: View { private var inputRow: some View { HStack(spacing: 7) { - TextField("Address or expression", text: $input) - .textFieldStyle(.plain) + LitheTextField("Address or expression", text: $input) .font(LitheTheme.uiFont(size: 12, design: .monospaced)) .onSubmit { disassembly.readMemory(from: input) } .accessibilityLabel("Memory address or expression") @@ -544,11 +542,11 @@ struct GenericDebugMemoryView: View { } } - /// The address the offsets count from, kept outside the scrolling rows so it can never + /// 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.address)") + Text("Offsets relative to \(memory.reference)") .foregroundStyle(LitheTheme.Tree.secondaryText) .lineLimit(1) .truncationMode(.middle) @@ -674,7 +672,7 @@ struct GenericDebugMemoryView: View { private static let rowHeight: CGFloat = 20 private static var characterWidth: CGFloat { - NSFont(name: "JetBrainsMono-Regular", size: 12)?.maximumAdvancement.width ?? 7.2 + LitheTheme.editorFont(size: 12).maximumAdvancement.width } /// `+0x0010` relative to the address that was asked for; the host never adds to an address. diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 18ac3ee691..f9ad5cd8a5 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -519,8 +519,12 @@ struct GenericDebugView: View { dividerColor: LitheTheme.toolWindowBorder(for: colorScheme), onCommit: { inspectorWidth = $0 }) { executionInspector + .disabled(feature.isInspectionStale || feature.isRefreshingVariables) + .opacity(feature.isInspectionStale || feature.isRefreshingVariables ? 0.6 : 1) } flexible: { data + .disabled(feature.isInspectionStale || feature.isRefreshingVariables) + .opacity(feature.isInspectionStale || feature.isRefreshingVariables ? 0.6 : 1) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift index f940694fd2..b6230ad430 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift @@ -52,7 +52,7 @@ public struct GenericDebugInstructionBreakpoint: Identifiable, Equatable, Sendab } } -/// One page of debuggee bytes. Lines are labelled with their byte offset from `address` +/// 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 @@ -243,8 +243,7 @@ public final class GenericDebugDisassemblyModel: ObservableObject { /// The debuggee resumed or the stack was thrown away, so every address is stale. public func invalidate() { - disassemblyGeneration &+= 1 - memoryGeneration &+= 1 + suspendRequests() currentAddress = nil rows = [] isLoading = false @@ -255,6 +254,15 @@ public final class GenericDebugDisassemblyModel: ObservableObject { 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. @@ -289,6 +297,7 @@ public final class GenericDebugDisassemblyModel: ObservableObject { loadFailure = nil request( session: session, + generation: token, reference: anchor, offset: -Self.instructionsBefore, count: Self.initialInstructionCount, @@ -316,6 +325,7 @@ public final class GenericDebugDisassemblyModel: ObservableObject { loadFailure = nil request( session: session, + generation: token, reference: edge.address, offset: direction == .before ? -Self.pageInstructionCount : 1, count: Self.pageInstructionCount, @@ -337,6 +347,7 @@ public final class GenericDebugDisassemblyModel: ObservableObject { /// at the instruction pointer instead of failing the whole view. private func request( session: any DebugAdapterControllingSession, + generation: Int, reference: String, offset: Int, count: Int, @@ -349,13 +360,15 @@ public final class GenericDebugDisassemblyModel: ObservableObject { 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, let self { + if retriesForward, offset < 0 { self.request( session: session, + generation: generation, reference: reference, offset: 0, count: count, diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index df59197dbd..1247fa990c 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -505,8 +505,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self.suspendOrClearStoppedInspection() } if state == .terminated || state == .failed { - self.retainsInspectionForStep = false - self.staleVariables = nil + self.clearStoppedInspection() self.outputPresentation.flush() } self.saveActiveSessionSnapshot() @@ -2034,6 +2033,9 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu 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 = [] } @@ -2211,10 +2213,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu case .stopped(let reason, let threadID, let description): outputPresentation.flush() isExecutionRequestPending = false - let generation = beginInspectionTransition() // 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) } else { @@ -2394,6 +2397,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private func beginInspectionTransition() -> Int { inspectionGeneration &+= 1 + disassembly.suspendRequests() activeSession?.cancelPendingOperations() return inspectionGeneration } diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 0926365121..eafa86de8a 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -4136,6 +4136,72 @@ struct DebugModuleTests { } } + @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() @@ -4170,6 +4236,28 @@ struct DebugModuleTests { #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() @@ -4862,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) diff --git a/shared/platform-feature-matrix/features/debug-disassembly.json b/shared/platform-feature-matrix/features/debug-disassembly.json index 88e591858c..3e16338f73 100644 --- a/shared/platform-feature-matrix/features/debug-disassembly.json +++ b/shared/platform-feature-matrix/features/debug-disassembly.json @@ -38,5 +38,5 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:用只带符号表、没有调试信息的 C 程序(含 raise(SIGTRAP))经 Xcode lldb-dap 启动调试,暂停后栈帧显示函数名与地址而不是伪造的文件和第 1 行,反汇编标签自动打开并高亮当前指令;适配器不声明 supportsDisassembleRequest 时没有反汇编标签。反汇编中单步改为按指令前进(仅适配器声明 supportsSteppingGranularity 时),关闭反汇编标签后恢复按行单步;点击地址栏设置或移除指令断点(仅适配器声明 supportsInstructionBreakpoints 时),断点只属于当前会话、不写入持久化,新会话不重放。反汇编打开时,步过/步入的按钮提示变为按指令单步,头部显示“按指令单步”;适配器不支持时头部提示单步仍按源码行前进。寄存器作用域出现在寄存器标签,内存标签接受地址或保存着地址的表达式,从目标地址前 64 字节读起并分页,目标字节在十六进制和 ASCII 两侧高亮并滚入可见区,读取不到的字节显示为缺失而不是 00;右侧显示设置可切换进制、每格字节数、每行字节数、字节序与 ASCII 列;只读内存,不开放 writeMemory。连续单步时线程、调用栈、变量、反汇编、内存与 JSON 视图原地更新,不整面板清空重画,变化的内存字节被标出,继续执行或会话结束才清空。地址和引用对宿主是不透明字符串,宿主不做地址运算、不内置 CPU 寄存器表,也不按适配器 ID 分支。Windows 尚未实现,共享 Rust 内核已经提供能力快照与 debug.inspect 的 disassemble、readMemory,待 React 界面接入。" + "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 界面接入。" }