From 88680564b97ba359f6b7466cb74a63081d0dfbae Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 21:25:07 +0800 Subject: [PATCH 1/3] feat(debug): #1210 implement direct TCP adapter lifecycle Unify hosted and external DAP byte streams behind a replaceable connection provider; reserve the seam for future network routes without adding proxy or tunnel behavior. Add explicit endpoint validation, per-connection remote consent, a one-shot launch/attach entry, bounded startup/write buffering, generation-fenced callbacks, and bounded disconnect draining. Keep the debuggee target separate and external resources user-owned. Cover routing, cancellation, queue limits, cleanup and real loopback Core DAP initialize/attach/disconnect. Update shared fixtures, localization, architecture notes and the platform capability matrix. Windows host and real debugger acceptance remain out of scope. --- ...026-10-27-debug-tcp-transport-lifecycle.md | 59 ++ ...7-debug-network-paths-and-tcp-lifecycle.md | 4 +- macos/Resources/en.lproj/Localizable.strings | 34 + .../zh-Hans.lproj/Localizable.strings | 34 + .../ExternalDebugAdapterConfiguration.swift | 31 + .../Models/AppModel/AppModel+Debugging.swift | 17 + .../MacExternalDebugAdapterTransport.swift | 50 ++ .../MacHostedPortDebugAdapterTransport.swift | 135 +--- .../MacPluginDebugAdapterTransport.swift | 8 +- .../MacServerDebugAdapterTransport.swift | 32 +- .../Debug/MacTCPDebugAdapterTransport.swift | 239 ++++++ .../Platform/MacOS/MacServiceContainer.swift | 29 + .../Debug/ExternalDebugAdapterView.swift | 65 ++ .../Lithe/Views/Debug/GenericDebugView.swift | 17 + .../Debug/DebugAdapterContracts.swift | 28 +- .../GenericDebugFeatureModel.swift | 3 +- .../CoreDebugAdapterProtocolSession.swift | 34 +- .../Runtime/DebugAdapterSessionManager.swift | 53 +- .../DebugModuleTests.swift | 51 ++ .../LitheTests/PluginDebugAdapterTests.swift | 5 +- .../RunConfigurationIntegrationTests.swift | 5 +- .../LitheTests/TCPDebugAdapterTests.swift | 718 ++++++++++++++++++ rust/lithe-core/src/debug/adapters.rs | 4 +- rust/lithe-core/src/debug/endpoint.rs | 60 ++ rust/lithe-core/src/debug/mod.rs | 2 + rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/runtime/dispatcher.rs | 6 + rust/lithe-core/src/tests/protocol.rs | 23 + shared/contracts/rust-core-api.md | 27 + .../fixtures/debug/adapter-endpoints-v1.json | 33 + shared/fixtures/debug/v1-baseline.json | 24 + shared/i18n/locales/en-US.json | 19 +- shared/i18n/locales/zh-CN.json | 19 +- shared/i18n/messages.json | 192 +++++ .../features/debug-tcp-transport.json | 33 + .../features/plugin-debug-adapters.json | 3 +- 36 files changed, 1961 insertions(+), 138 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md create mode 100644 macos/Sources/Lithe/Application/Features/ExternalDebugAdapterConfiguration.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift create mode 100644 macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift create mode 100644 macos/Tests/LitheTests/TCPDebugAdapterTests.swift create mode 100644 rust/lithe-core/src/debug/endpoint.rs create mode 100644 shared/fixtures/debug/adapter-endpoints-v1.json create mode 100644 shared/platform-feature-matrix/features/debug-tcp-transport.json diff --git a/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md b/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md new file mode 100644 index 0000000000..8744f9ea5a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md @@ -0,0 +1,59 @@ +# Agent 笔记:普通 TCP 调试连接与资源所有权 + +状态:已实现 + +## 先说结论 + +macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器返回的本机端口,以及用户显式填写的外部 DAP(编辑器与调试适配器之间的协议)地址,都交给同一个有界连接生命周期管理。连接地址和被调试程序的地址是两件事;前者放在宿主配置的 `adapterEndpoint`,后者仍按适配器自己的启动或附加参数提供。 + +本次只实现直连,不创建代理、SSH、隧道或端口转发。未来网络通路通过可替换的连接工厂接入,不在 DAP 协议状态机里拼接网络命令。完整网络方案仍见[独立提案](../../proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md)。 + +## 问题 + +原有语言服务器端口传输可以连接本机 Java Debug Server,但没有待发数据总量限制,也没有逐次写入完成后再发送的压力控制。简单放开回环地址限制既不能补齐这些生命周期问题,也会混淆两条不同连接的权限。 + +例如远端 JVM 的 JDWP(Java 调试线协议)端口是 Java 适配器访问的目标,不是 Lithe 应向其发送 DAP 的端口。默认 debugpy 的监听端口则可以是 DAP 地址,二者不能用“远程端口”这一个字段表示。 + +## 决策 + +- 复用 Apple Network 的 `NWConnection` 建立 TCP、解析主机并收发字节,不实现另一套 socket、DNS 或代理协议栈。Rust Core 仍负责已有的 DAP 帧、请求顺序与操作超时;平台只管理网络字节流。 +- `MacTCPDebugAdapterTransport` 管理端点解析与建连的合计期限、单个在途写入的期限、待发字节和消息数量预算。不设置会话总时长或空闲读取期限:用户停在断点上不是网络失败。写入完成只表示本机网络栈接受数据,不能据此声称适配器已处理请求。 +- 直连的默认准备和写入分别为 10 秒;语言服务器冷启动与项目导入的端点发现允许 120 秒。显式外部 DAP 另有包含建连的 10 秒初始化期限,避免误连到非 DAP 服务后永久停在初始化;这些期限都不限制已建立会话的空闲时间。待发队列含在途数据最多 8 MiB、1024 次写入。超限或超时结束该连接,不静默丢帧、不重连或重放。失联不承诺立即可知,DAP 请求仍受原有操作期限约束。 +- 用户停止会话时,先把已有 DAP disconnect 帧按原顺序交给网络栈,再关闭连接,最多等 1 秒;这不是等待远端确认,不能保证远端必然收到。传输的显式 `stop()` 仍立即中止,EOF、读写失败和超时也立即清理;不会为排空而启动尚未建立的连接。排空期间拒绝新数据、不向已结束会话投影晚到消息,超时由有界计时器持有并回收 socket。 +- 停止、EOF、读写失败和超时先撤销连接代次,再取消解析任务、计时器和 socket,清空回调与缓冲。旧连接回调以及旧写入完成不能影响新连接。显式停止由会话层投影结果,传输不重复发送终止通知。 +- `DebugAdapterConnectionProviding` 是未来代理的替换点:返回尚未启动、可取消的连接,握手纳入已有启动期限;停止必须释放它自己拥有的资源。本次唯一实现为直连,不提前增加代理配置、密码或进程字段。 +- `tcpLanguageServer` 仍只解析同插件语言服务器返回的回环端口。外部入口在 Debug 的更多操作中,由当前文件选择已安装的调试提供方,单独填写 DAP 地址、请求种类和适配器 JSON 参数;不猜测语言协议,不启动本地替代进程。Core 先校验地址,非回环端点每次连接前确认,明确警告裸 TCP 没有加密或认证;确认时间不计入网络建连期限。 +- 外部服务只归用户所有。关闭 socket 不停止共享语言服务器、外部适配器、目标程序或用户隧道;DAP `attach` 的目标结束策略仍由现有协议层负责。配置不持久化,源码映射继续由适配器参数表达。 + +正确:把用户已配置的隧道最终地址填为 DAP 端点,Lithe 只连接它,停止时只关闭自己的连接。 + +不要:把远端 `gdbserver` 或 JVM JDWP 地址填成 DAP 地址,或在连接工厂中私自启动 `ssh -L`、停止用户已有隧道。 + +## 考虑过的备选方案 + +- **在旧端口传输上直接放开主机限制。** 缺少独立来源与确认,而且不能保护旧回环信任边界;改为保留端点来源并共用底层生命周期。 +- **为外部 DAP 新写一个 TCP 传输。** 取消、缓冲和晚到回调会出现两套规则;改为端点解析与连接建立分开复用。 +- **本轮同时实现代理或统一成 SSH。** 认证、身份校验、共享隧道和辅助进程归属还需要独立设计;没有 SSH 也可以直连或经过用户的 VPN,不把 SSH 当作必要前提。 + +## 后果 + +普通 TCP 连接可独立测试,stdio 原有路径不变,用户提供的外部地址与语言服务器端口遵守各自的信任边界。代价是外部连接需要用户了解其适配器参数,没有保存配置、通路自动建立或断线恢复;插件 `tcpPort`、`tcpAnnounced` 的托管进程模式也仍未实现。 + +此处代码实现不等于所有语言适配器都已验收。真实 JVM、debugpy、GDB 的兼容性仍需对应插件和端到端场景证明;Windows 只共享地址校验,尚无本次宿主 TCP 实现。 + +外部入口复用当前文件的调试提供方索引,该索引仍只选择支持文件启动的插件(Java 使用宿主提供方)。因此本次不支持单独发现仅附加进程、可执行文件或其他非文件型插件;不要把 TCP 字节流可用等同于这些插件的产品入口已经接通。 + +## 验证 + +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'TCPDebugAdapterTests|PluginDebugAdapterTests|DebugModuleTests'`:覆盖可控时钟、队列预算、取消与晚到回调、确认顺序、会话选路;临时回环监听器验证双向字节以及真实 Core 的 initialize/attach 帧往返,监听服务不随客户端停止。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core debug_adapter_endpoint` 和 `cargo test --manifest-path rust/Cargo.toml -p lithe-core dap_endpoint_contract`:共享 fixture 校验及 JSON dispatcher 边界。 +- `./scripts/verify-shared-contracts.sh`、`./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-agent-notes.sh`。 + +## 适用范围 + +- `macos/Sources/Lithe/Platform/MacOS/Debug/` +- `macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift` +- `macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift` +- `rust/lithe-core/src/debug/endpoint.rs` +- `shared/fixtures/debug/adapter-endpoints-v1.json` diff --git a/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md b/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md index f449853fce..06b51b1bc3 100644 --- a/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md +++ b/.agents/notes/proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md @@ -8,6 +8,8 @@ 远程调试不等于必须使用 SSH:端点可以直接可达,也可以经用户自己配置的 VPN 或隧道可达;Lithe 本轮只连接最终可达的端点,不创建或管理这些网络设施。[#1525](https://github.com/1lck/Lithe-IDEA/pull/1525) 当前只实现远程 attach 的契约与 Rust 校验;本 Note 记录后续连接实现的边界和验收要求,不表示宿主已经支持全部场景。 +普通 TCP 与显式外部端点入口的已实现边界见[普通 TCP 生命周期决策](../../implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md)。本提案保留未实施的代理、隧道和网络通路问题,不以传输测试代替真实语言调试验收。 + ## 问题 一次远程调试可能有两条不同的连接,不能把它们的主机、端口和权限混成一个目标: @@ -82,7 +84,7 @@ IDE -- DAP(调试适配器协议,stdio 或 TCP)--> 调试适配器 ## 验证 -本轮仅补充设计:运行 `./scripts/verify-agent-notes.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-platform-feature-matrix.sh` 和 `git diff --check`,不据此声称有运行时连接证据。 +原提案阶段仅补充设计;TCP 实现与验证入口以已实现决策为准。维护本提案时运行 `./scripts/verify-agent-notes.sh`、`./scripts/verify-shared-contracts.sh`、`./scripts/verify-platform-feature-matrix.sh` 和 `git diff --check`,不据此声称有运行时连接证据。 后续实现先加载 `write-stable-tests`,用可控 socket 和有界等待覆盖验收标准,再执行对应平台测试与真实适配器流程;同步平台功能矩阵,验证发行包只读,清理本次启动的所有进程。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 3df02b6ab3..f023147cd2 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -203,6 +203,7 @@ "Active Maven Profiles" = "Active Maven Profiles"; "Active profiles" = "Active profiles"; "Activity" = "Activity"; +"Adapter Arguments (JSON)" = "Adapter Arguments (JSON)"; "Adapter Ready" = "Adapter Ready"; "Add" = "Add"; "Add Hook" = "Add Hook"; @@ -376,6 +377,7 @@ "Assignees" = "Assignees"; "Astro Support" = "Astro Support"; "Attach" = "Attach"; +"Attach Request" = "Attach Request"; "Attach file" = "Attach file"; "Attach files" = "Attach files"; "Attach to a remote JVM or Tomcat" = "Attach to a remote JVM or Tomcat"; @@ -841,8 +843,12 @@ "Conflicts with %@" = "Conflicts with %@"; "Connect" = "Connect"; "Connect a GitHub account before continuing" = "Connect a GitHub account before continuing"; +"Connect to Debug Adapter" = "Connect to Debug Adapter"; +"Connect to Debug Adapter…" = "Connect to Debug Adapter…"; "Connect to Running JVM" = "Connect to Running JVM"; "Connect to Running JVM…" = "Connect to Running JVM…"; +"Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created." = "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created."; +"Connect to an external debug adapter?" = "Connect to an external debug adapter?"; "Connect to databases, browse schemas, edit data, run SQL, and manage backups from the Database workspace." = "Connect to databases, browse schemas, edit data, run SQL, and manage backups from the Database workspace."; "Connect to running JVM" = "Connect to running JVM"; "Connected" = "Connected"; @@ -1302,6 +1308,7 @@ "Enlarge image" = "Enlarge image"; "Enter API key" = "Enter API key"; "Enter a ChangeList name (1–100 characters)." = "Enter a ChangeList name (1–100 characters)."; +"Enter a JSON object of adapter arguments no larger than 256 KiB." = "Enter a JSON object of adapter arguments no larger than 256 KiB."; "Enter a JVM debug port between 1 and 65535." = "Enter a JVM debug port between 1 and 65535."; "Enter a branch name" = "Enter a branch name"; "Enter a branch name before publishing." = "Enter a branch name before publishing."; @@ -1318,6 +1325,7 @@ "Enter a size such as 200K or 1M, between 10K and 10M." = "Enter a size such as 200K or 1M, between 10K and 10M."; "Enter a tag name" = "Enter a tag name"; "Enter a tag name, branch name, commit hash, or other Git revision." = "Enter a tag name, branch name, commit hash, or other Git revision."; +"Enter a valid debug adapter host and a port between 1 and 65535." = "Enter a valid debug adapter host and a port between 1 and 65535."; "Enter a value." = "Enter a value."; "Enter a whole number from %d to %d." = "Enter a whole number from %d to %d."; "Enter address" = "Enter address"; @@ -1330,6 +1338,7 @@ "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."; "Enter the host name of the running JVM." = "Enter the host name of the running JVM."; "Enter the host without a user name or spaces; put the user in User name." = "Enter the host without a user name or spaces; put the user in User name."; +"Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address." = "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address."; "Enter the new symbol name." = "Enter the new symbol name."; "Enter the next function call" = "Enter the next function call"; "Enter the one-time code" = "Enter the one-time code"; @@ -1384,6 +1393,7 @@ "Export…" = "Export…"; "Expression" = "Expression"; "Expression history" = "Expression history"; +"External Debug Adapter" = "External Debug Adapter"; "External change" = "External change"; "External edits conflict with unsaved changes" = "External edits conflict with unsaved changes"; "Extra deep reasoning for demanding tasks" = "Extra deep reasoning for demanding tasks"; @@ -1866,6 +1876,7 @@ "Latest Version" = "Latest Version"; "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals." = "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals."; "Launch" = "Launch"; +"Launch Request" = "Launch Request"; "Launching" = "Launching"; "Layout" = "Layout"; "Leave a review" = "Leave a review"; @@ -2965,6 +2976,7 @@ "Repository changed; review and retry" = "Repository changed; review and retry"; "Repository identity overrides inherited global values. Clear an override to use inherited settings." = "Repository identity overrides inherited global values. Clear an override to use inherited settings."; "Repository needs attention" = "Repository needs attention"; +"Request" = "Request"; "Request changes" = "Request changes"; "Requested by the plugin “%@”." = "Requested by the plugin “%@”."; "Required fields are marked with *" = "Required fields are marked with *"; @@ -3728,8 +3740,13 @@ "The debug adapter %@ names no language server to host it." = "The debug adapter %@ names no language server to host it."; "The debug adapter %@ needs %@, which was not found. Install it, then try again." = "The debug adapter %@ needs %@, which was not found. Install it, then try again."; "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." = "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported."; +"The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured." = "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured."; "The debug adapter connection is stopped." = "The debug adapter connection is stopped."; +"The debug adapter connection timed out." = "The debug adapter connection timed out."; +"The debug adapter connection was not approved." = "The debug adapter connection was not approved."; +"The debug adapter did not accept outgoing data in time." = "The debug adapter did not accept outgoing data in time."; "The debug adapter did not provide a terminal executable." = "The debug adapter did not provide a terminal executable."; +"The debug adapter outgoing buffer is full." = "The debug adapter outgoing buffer is full."; "The debug adapter provided an invalid terminal working directory." = "The debug adapter provided an invalid terminal working directory."; "The debugger did not return object fields in time." = "The debugger did not return object fields in time."; "The default ChangeList cannot be renamed." = "The default ChangeList cannot be renamed."; @@ -4365,6 +4382,23 @@ "code" = "code"; "commit messages" = "commit messages"; "conflict -> %@" = "conflict -> %@"; +"debug.tcp.arguments" = "Adapter Arguments (JSON)"; +"debug.tcp.argumentsHelp" = "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address."; +"debug.tcp.attachRequest" = "Attach Request"; +"debug.tcp.bufferFull" = "The debug adapter outgoing buffer is full."; +"debug.tcp.confirmBody" = "The debug adapter at %1$@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured."; +"debug.tcp.confirmTitle" = "Connect to an external debug adapter?"; +"debug.tcp.declined" = "The debug adapter connection was not approved."; +"debug.tcp.help" = "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created."; +"debug.tcp.invalidArguments" = "Enter a JSON object of adapter arguments no larger than 256 KiB."; +"debug.tcp.invalidEndpoint" = "Enter a valid debug adapter host and a port between 1 and 65535."; +"debug.tcp.launchRequest" = "Launch Request"; +"debug.tcp.menu" = "Connect to Debug Adapter…"; +"debug.tcp.name" = "External Debug Adapter"; +"debug.tcp.request" = "Request"; +"debug.tcp.startupTimedOut" = "The debug adapter connection timed out."; +"debug.tcp.title" = "Connect to Debug Adapter"; +"debug.tcp.writeTimedOut" = "The debug adapter did not accept outgoing data in time."; "debugger.continue" = "Continue"; "debugger.threadsAndVariables" = "Threads & Variables"; "detected on this Mac" = "detected on this Mac"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 246161ab78..9531158864 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -203,6 +203,7 @@ "Active Maven Profiles" = "启用的 Maven Profiles"; "Active profiles" = "启用的 Profiles"; "Activity" = "活动"; +"Adapter Arguments (JSON)" = "适配器参数(JSON)"; "Adapter Ready" = "适配器已就绪"; "Add" = "添加"; "Add Hook" = "添加 Hook"; @@ -376,6 +377,7 @@ "Assignees" = "负责人"; "Astro Support" = "Astro 支持"; "Attach" = "连接"; +"Attach Request" = "附加请求"; "Attach file" = "附件"; "Attach files" = "添加文件"; "Attach to a remote JVM or Tomcat" = "连接到远程 JVM 或 Tomcat"; @@ -841,8 +843,12 @@ "Conflicts with %@" = "与 %@ 冲突"; "Connect" = "连接"; "Connect a GitHub account before continuing" = "请先连接 GitHub 账号"; +"Connect to Debug Adapter" = "连接调试适配器"; +"Connect to Debug Adapter…" = "连接调试适配器…"; "Connect to Running JVM" = "连接到运行中的 JVM"; "Connect to Running JVM…" = "连接到运行中的 JVM…"; +"Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created." = "使用当前文件已安装的调试器连接已运行的 DAP 服务。这不是 JVM JDWP 或 gdbserver 端口。不会创建代理或隧道。"; +"Connect to an external debug adapter?" = "连接到外部调试适配器?"; "Connect to databases, browse schemas, edit data, run SQL, and manage backups from the Database workspace." = "连接数据库、浏览结构、编辑数据、运行 SQL,并在数据库工作区中管理备份。"; "Connect to running JVM" = "连接到正在运行的 JVM"; "Connected" = "已连接"; @@ -1302,6 +1308,7 @@ "Enlarge image" = "放大查看图片"; "Enter API key" = "填写 API Key"; "Enter a ChangeList name (1–100 characters)." = "请输入更改列表名称(1–100 个字符)。"; +"Enter a JSON object of adapter arguments no larger than 256 KiB." = "请输入不超过 256 KiB 的适配器参数 JSON 对象。"; "Enter a JVM debug port between 1 and 65535." = "请输入 1 到 65535 之间的 JVM 调试端口。"; "Enter a branch name" = "请输入分支名称"; "Enter a branch name before publishing." = "请输入分支名称后再发布。"; @@ -1318,6 +1325,7 @@ "Enter a size such as 200K or 1M, between 10K and 10M." = "请输入如 200K 或 1M 的大小,范围为 10K 到 10M。"; "Enter a tag name" = "请输入标签名称"; "Enter a tag name, branch name, commit hash, or other Git revision." = "输入标签名、分支名、提交 Hash 或其他 Git 修订版本。"; +"Enter a valid debug adapter host and a port between 1 and 65535." = "请输入有效的调试适配器主机和 1 到 65535 之间的端口。"; "Enter a value." = "请输入内容。"; "Enter a whole number from %d to %d." = "请输入 %d 到 %d 之间的整数。"; "Enter address" = "输入网址或本地路径"; @@ -1330,6 +1338,7 @@ "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,也不解释管道或重定向。"; "Enter the host name of the running JVM." = "请输入正在运行的 JVM 的主机名。"; "Enter the host without a user name or spaces; put the user in User name." = "主机里不要包含用户名或空格,用户名请填在“用户名”里。"; +"Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address." = "输入此适配器所需的启动或附加参数,必要时包含源码路径映射。这些参数独立于 DAP 服务的 TCP 地址。"; "Enter the new symbol name." = "输入新的符号名称。"; "Enter the next function call" = "进入下一个函数调用"; "Enter the one-time code" = "输入一次性代码"; @@ -1384,6 +1393,7 @@ "Export…" = "导出…"; "Expression" = "表达式"; "Expression history" = "表达式历史"; +"External Debug Adapter" = "外部调试适配器"; "External change" = "外部更改"; "External edits conflict with unsaved changes" = "外部编辑与未保存的更改发生冲突"; "Extra deep reasoning for demanding tasks" = "额外深度推理,适合高难度任务"; @@ -1866,6 +1876,7 @@ "Latest Version" = "最新版本"; "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals." = "最近一轮由 Agent 上报的计数,统计口径由 Agent 决定;开启“模型调用统计”后显示整个会话的合计。"; "Launch" = "启动方式"; +"Launch Request" = "启动请求"; "Launching" = "启动中"; "Layout" = "布局"; "Leave a review" = "提交审查"; @@ -2965,6 +2976,7 @@ "Repository changed; review and retry" = "仓库状态已改变,请检查并重试"; "Repository identity overrides inherited global values. Clear an override to use inherited settings." = "仓库身份会覆盖继承的全局值。清除覆盖后将使用继承的配置。"; "Repository needs attention" = "仓库需要处理"; +"Request" = "请求"; "Request changes" = "请求修改"; "Requested by the plugin “%@”." = "由插件“%@”请求。"; "Required fields are marked with *" = "带 * 的字段为必填项"; @@ -3728,8 +3740,13 @@ "The debug adapter %@ names no language server to host it." = "调试适配器 %@ 没有指定承载它的语言服务器。"; "The debug adapter %@ needs %@, which was not found. Install it, then try again." = "调试适配器 %@ 需要 %@,但未找到。请先安装,然后重试。"; "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." = "调试适配器 %@ 使用 %@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。"; +"The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured." = "位于 %@ 的调试适配器可以访问你的调试数据。普通 TCP 不提供加密或身份认证。请仅通过可信网络或你自行配置的隧道连接。"; "The debug adapter connection is stopped." = "调试适配器的连接已停止。"; +"The debug adapter connection timed out." = "调试适配器连接超时。"; +"The debug adapter connection was not approved." = "未批准连接调试适配器。"; +"The debug adapter did not accept outgoing data in time." = "调试适配器未能及时接收待发送数据。"; "The debug adapter did not provide a terminal executable." = "调试适配器没有提供终端可执行文件。"; +"The debug adapter outgoing buffer is full." = "调试适配器的发送缓冲区已满。"; "The debug adapter provided an invalid terminal working directory." = "调试适配器提供的终端工作目录无效。"; "The debugger did not return object fields in time." = "调试器未能及时返回对象字段。"; "The default ChangeList cannot be renamed." = "默认更改列表不能重命名。"; @@ -4365,6 +4382,23 @@ "code" = "代码"; "commit messages" = "提交信息"; "conflict -> %@" = "版本冲突 → %@"; +"debug.tcp.arguments" = "适配器参数(JSON)"; +"debug.tcp.argumentsHelp" = "输入此适配器所需的启动或附加参数,必要时包含源码路径映射。这些参数独立于 DAP 服务的 TCP 地址。"; +"debug.tcp.attachRequest" = "附加请求"; +"debug.tcp.bufferFull" = "调试适配器的发送缓冲区已满。"; +"debug.tcp.confirmBody" = "位于 %1$@ 的调试适配器可以访问你的调试数据。普通 TCP 不提供加密或身份认证。请仅通过可信网络或你自行配置的隧道连接。"; +"debug.tcp.confirmTitle" = "连接到外部调试适配器?"; +"debug.tcp.declined" = "未批准连接调试适配器。"; +"debug.tcp.help" = "使用当前文件已安装的调试器连接已运行的 DAP 服务。这不是 JVM JDWP 或 gdbserver 端口。不会创建代理或隧道。"; +"debug.tcp.invalidArguments" = "请输入不超过 256 KiB 的适配器参数 JSON 对象。"; +"debug.tcp.invalidEndpoint" = "请输入有效的调试适配器主机和 1 到 65535 之间的端口。"; +"debug.tcp.launchRequest" = "启动请求"; +"debug.tcp.menu" = "连接调试适配器…"; +"debug.tcp.name" = "外部调试适配器"; +"debug.tcp.request" = "请求"; +"debug.tcp.startupTimedOut" = "调试适配器连接超时。"; +"debug.tcp.title" = "连接调试适配器"; +"debug.tcp.writeTimedOut" = "调试适配器未能及时接收待发送数据。"; "debugger.continue" = "继续"; "debugger.threadsAndVariables" = "线程和变量"; "detected on this Mac" = "本机检测到"; diff --git a/macos/Sources/Lithe/Application/Features/ExternalDebugAdapterConfiguration.swift b/macos/Sources/Lithe/Application/Features/ExternalDebugAdapterConfiguration.swift new file mode 100644 index 0000000000..76c9f1b905 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/ExternalDebugAdapterConfiguration.swift @@ -0,0 +1,31 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// A one-shot DAP connection draft. The adapter owns its launch/attach arguments; +/// its TCP address stays outside them and never becomes a debuggee address. +enum ExternalDebugAdapterConfiguration { + static let maximumArgumentBytes = 256 * 1_024 + + enum ValidationError: LocalizedError { + case invalidArguments + + var errorDescription: String? { + L10n.string("Enter a JSON object of adapter arguments no larger than 256 KiB.") + } + } + + static func make( + host: String, port: Int, request: DebugRequestKind, argumentsJSON: String + ) throws -> DebugLaunchConfiguration { + let data = Data(argumentsJSON.utf8) + guard data.count <= maximumArgumentBytes, + let arguments = try? JSONDecoder().decode([String: ToolingJSONValue].self, from: data) else { + throw ValidationError.invalidArguments + } + return DebugLaunchConfiguration( + name: L10n.string("External Debug Adapter"), request: request, arguments: arguments, + adapterEndpoint: DebugAdapterEndpoint(host: host, port: port) + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index ca2adcb3f2..fe36833dfa 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -100,6 +100,23 @@ extension AppModel { } } + func connectExternalDebugAdapter(configuration: DebugLaunchConfiguration, fileURL: URL) { + guard let identity = currentWorkspaceIdentity, let workspaceURL else { return } + Task { [weak self] in + guard let self, + let feature = await self.activateDebugModule()?.genericFeature, + self.isCurrentWorkspace(identity), !Task.isCancelled, + !feature.isSessionActive else { return } + // The selected source chooses an installed provider, not a wire + // protocol guessed from the port. A failed connection stays visible + // in Debug, rather than restoring an older session over its error. + _ = feature.start( + fileURL: fileURL, rootURL: workspaceURL, configuration: configuration + ) + self.showDebugToolWindow() + } + } + private func attachJavaDebuggerAfterActivation(host: String, port: Int) async { guard let workspaceURL, await activateDebugModule() != nil else { return } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift new file mode 100644 index 0000000000..130b34ba82 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift @@ -0,0 +1,50 @@ +import AppKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// User endpoints are not plugin declarations or debuggee attach arguments. +/// Validate before displaying consent, then let the same TCP lifecycle own only +/// the connection. Stopping it never kills the already running external server. +@MainActor +enum MacExternalDebugAdapterTransport { + struct ValidatedEndpoint: Decodable { + let host: String + let port: Int + let remote: Bool + } + + static func make( + endpoint: DebugAdapterEndpoint, + validate: @MainActor (DebugAdapterEndpoint) throws -> ValidatedEndpoint, + confirm: @MainActor (DebugAdapterEndpoint) -> Bool = confirmConnection, + connectionProvider: (any DebugAdapterConnectionProviding)? = nil + ) throws -> MacTCPDebugAdapterTransport { + let checked = try validate(endpoint) + let destination = DebugAdapterEndpoint(host: checked.host, port: checked.port) + // Human approval is not a timed network operation. Do it before creating + // the transport, so time spent reading the warning cannot expire startup. + if checked.remote, !confirm(destination) { + throw MacTCPDebugAdapterTransport.TransportError.connectionDeclined + } + return MacTCPDebugAdapterTransport( + endpointResolver: { _ in destination }, + connectionProvider: connectionProvider + ) + } + + private static func confirmConnection(_ endpoint: DebugAdapterEndpoint) -> Bool { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = L10n.string("Connect to an external debug adapter?") + let address = endpoint.host.contains(":") ? "[\(endpoint.host)]:\(endpoint.port)" : "\(endpoint.host):\(endpoint.port)" + alert.informativeText = String( + format: L10n.string("The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured."), + address + ) + // No remembered approval and no approval inferred from a loopback debuggee. + alert.addButton(withTitle: L10n.string("Cancel")) + alert.addButton(withTitle: L10n.string("Connect")) + return alert.runModal() == .alertSecondButtonReturn + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift index 13dc117b35..d2b09fb37c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift @@ -7,7 +7,7 @@ import LitheCoreContracts /// activation stays in the language module and DAP state stays in Core; this /// transport owns only asynchronous port discovery and the native TCP socket. @MainActor -final class MacHostedPortDebugAdapterTransport: DebugAdapterTransport { +final class MacHostedPortDebugAdapterTransport: DebugAdapterDrainingTransport { enum TransportError: LocalizedError { case languageIntelligenceUnavailable case connectionFailed(String) @@ -27,18 +27,20 @@ final class MacHostedPortDebugAdapterTransport: DebugAdapterTransport { typealias PortResolver = @MainActor (URL) async throws -> UInt16 - private let portResolver: PortResolver - private let socketFactory: @MainActor (String, UInt16) -> any DebugAdapterSocketConnection - private var startupTask: Task? - private var socket: (any DebugAdapterSocketConnection)? - private var pendingWrites: [Data] = [] - private var isSocketReady = false - private var generation = UUID() - private(set) var isRunning = false - - var onData: ((Data) -> Void)? - var onErrorOutput: ((Data) -> Void)? - var onTermination: ((Int) -> Void)? + private let transport: MacTCPDebugAdapterTransport + var isRunning: Bool { transport.isRunning } + var onData: ((Data) -> Void)? { + get { transport.onData } + set { transport.onData = newValue } + } + var onErrorOutput: ((Data) -> Void)? { + get { transport.onErrorOutput } + set { transport.onErrorOutput = newValue } + } + var onTermination: ((Int) -> Void)? { + get { transport.onTermination } + set { transport.onTermination = newValue } + } init( portResolver: @escaping PortResolver, @@ -46,99 +48,20 @@ final class MacHostedPortDebugAdapterTransport: DebugAdapterTransport { NetworkDebugAdapterSocketConnection(host: $0, port: $1) } ) { - self.portResolver = portResolver - self.socketFactory = socketFactory - } - - func start(rootURL: URL) throws { - guard !isRunning else { return } - isRunning = true - isSocketReady = false - pendingWrites = [] - generation = UUID() - let currentGeneration = generation - let portResolver = portResolver - startupTask = Task { @MainActor [weak self] in - do { - let port = try await portResolver(rootURL.standardizedFileURL) - try Task.checkCancellation() - guard let self else { return } - guard isRunning, generation == currentGeneration else { return } - connect(port: port, generation: currentGeneration) - } catch is CancellationError { - return - } catch { - guard let self else { return } - guard isRunning, generation == currentGeneration else { return } - fail(error) - } - } - } - - func send(_ data: Data) throws { - guard isRunning else { throw TransportError.stopped } - guard isSocketReady, let socket else { - pendingWrites.append(data) - return - } - socket.send(data) - } - - func stop() { - generation = UUID() - startupTask?.cancel() - startupTask = nil - socket?.stop() - socket = nil - pendingWrites = [] - isSocketReady = false - isRunning = false - } - - private func connect(port: UInt16, generation: UUID) { - let socket = socketFactory("127.0.0.1", port) - self.socket = socket - socket.onReady = { [weak self] in - guard let self, self.generation == generation else { return } - self.socketDidBecomeReady() - } - socket.onData = { [weak self] data in - guard let self, self.generation == generation else { return } - self.onData?(data) - } - socket.onFailure = { [weak self] error in - guard let self, self.generation == generation else { return } - self.fail(TransportError.connectionFailed(error.localizedDescription)) - } - socket.onComplete = { [weak self] in - guard let self, self.generation == generation else { return } - self.terminate(exitCode: 0) - } - socket.start() - } - - private func socketDidBecomeReady() { - guard let socket, isRunning else { return } - isSocketReady = true - let writes = pendingWrites - pendingWrites = [] - writes.forEach(socket.send) + transport = MacTCPDebugAdapterTransport( + endpointResolver: { root in + let port = try await portResolver(root) + return DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) + }, + // A cold JDT/project import is endpoint discovery, not a TCP + // connect. Keep it bounded without applying the direct-host budget. + limits: .init(startupMilliseconds: 120_000), + socketFactory: { socketFactory($0.host, UInt16($0.port)) } + ) } - private func fail(_ error: Error) { - onErrorOutput?(Data((error.localizedDescription + "\n").utf8)) - terminate(exitCode: 1) - } - - private func terminate(exitCode: Int) { - guard isRunning else { return } - startupTask?.cancel() - startupTask = nil - socket?.stop() - socket = nil - pendingWrites = [] - isSocketReady = false - isRunning = false - onTermination?(exitCode) - } + func start(rootURL: URL) throws { try transport.start(rootURL: rootURL) } + func send(_ data: Data) throws { try transport.send(data) } + func stop() { transport.stop() } + func finishSendingAndStop() { transport.finishSendingAndStop() } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift index d04d4851de..56e86e477f 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift @@ -8,7 +8,7 @@ import LitheModuleAPI /// starts, not when the provider is registered; a rejected plan surfaces as the /// start error. @MainActor -final class MacPluginDebugAdapterTransport: DebugAdapterTransport { +final class MacPluginDebugAdapterTransport: DebugAdapterDrainingTransport { enum TransportError: LocalizedError, Equatable { case unsupportedExecutable(adapterID: String) case commandNotFound(adapterID: String, commands: [String]) @@ -112,4 +112,10 @@ final class MacPluginDebugAdapterTransport: DebugAdapterTransport { func stop() { transport?.stop() } + + func finishSendingAndStop() { + if let draining = transport as? any DebugAdapterDrainingTransport { + draining.finishSendingAndStop() + } else { transport?.stop() } + } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift index c729039936..daf3fb4e1b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift @@ -10,7 +10,7 @@ protocol DebugAdapterSocketConnection: AnyObject { var onFailure: ((Error) -> Void)? { get set } var onComplete: (() -> Void)? { get set } func start() - func send(_ data: Data) + func send(_ data: Data, completion: @escaping @MainActor @Sendable (Result) -> Void) func stop() } @@ -22,6 +22,7 @@ final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { var onData: ((Data) -> Void)? var onFailure: ((Error) -> Void)? var onComplete: (() -> Void)? + private var stopped = false init(host: String, port: UInt16) { connection = NWConnection( @@ -31,7 +32,7 @@ final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { ) connection.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in - guard let self else { return } + guard let self, !self.stopped else { return } switch state { case .ready: self.onReady?() case .failed(let error): self.onFailure?(error) @@ -42,25 +43,42 @@ final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { } } + deinit { connection.cancel() } + func start() { + guard !stopped else { return } connection.start(queue: queue) receiveNext() } - func send(_ data: Data) { + func send(_ data: Data, completion: @escaping @MainActor @Sendable (Result) -> Void) { + guard !stopped else { return } connection.send(content: data, completion: .contentProcessed { [weak self] error in - guard let error else { return } - Task { @MainActor [weak self] in self?.onFailure?(error) } + Task { @MainActor [weak self] in + guard let self, !self.stopped else { return } + if let error { completion(.failure(error)) } + else { completion(.success(())) } + } }) } - func stop() { connection.cancel() } + func stop() { + guard !stopped else { return } + stopped = true + connection.stateUpdateHandler = nil + onReady = nil + onData = nil + onFailure = nil + onComplete = nil + connection.cancel() + } private func receiveNext() { connection.receive(minimumIncompleteLength: 1, maximumLength: 1_048_576) { [weak self] data, _, complete, error in Task { @MainActor [weak self] in - guard let self else { return } + guard let self, !self.stopped else { return } if let data, !data.isEmpty { self.onData?(data) } + guard !self.stopped else { return } if let error { self.onFailure?(error) } else if complete { diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift new file mode 100644 index 0000000000..489243d452 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift @@ -0,0 +1,239 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Establishes a byte stream, not a DAP session. Future network routes implement +/// this seam and return an owned, cancellable connection; direct TCP is the only +/// implementation today. Credentials and tunnel processes do not belong here. +@MainActor +protocol DebugAdapterConnectionProviding { + func makeConnection(to endpoint: DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection +} + +@MainActor +struct DirectDebugAdapterConnectionProvider: DebugAdapterConnectionProviding { + func makeConnection(to endpoint: DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection { + NetworkDebugAdapterSocketConnection(host: endpoint.host, port: UInt16(endpoint.port)) + } +} + +/// One owned TCP stream. DAP framing, requests and their deadlines stay in Core. +/// A startup deadline covers endpoint discovery and connection establishment; +/// write deadlines cover a stalled peer, never the duration of a debug session. +// Note: .agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md +@MainActor +final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { + enum TransportError: LocalizedError, Equatable { + case stopped, startupTimedOut, writeTimedOut, bufferLimit, invalidEndpoint, connectionDeclined + + var errorDescription: String? { + switch self { + case .stopped: return L10n.string("The debug adapter connection is stopped.") + case .startupTimedOut: return L10n.string("The debug adapter connection timed out.") + case .writeTimedOut: return L10n.string("The debug adapter did not accept outgoing data in time.") + case .bufferLimit: return L10n.string("The debug adapter outgoing buffer is full.") + case .invalidEndpoint: return L10n.string("Enter a valid debug adapter host and a port between 1 and 65535.") + case .connectionDeclined: return L10n.string("The debug adapter connection was not approved.") + } + } + } + + struct Limits { + var startupMilliseconds = 10_000 + var writeMilliseconds = 10_000 + var bufferedBytes = 8 * 1_024 * 1_024 + var bufferedWrites = 1_024 + var drainMilliseconds = 1_000 + } + + typealias EndpointResolver = @MainActor (URL) async throws -> DebugAdapterEndpoint + private let resolveEndpoint: EndpointResolver + private let makeSocket: @MainActor (DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection + private let scheduler: any DebugOperationDeadlineScheduling + private let limits: Limits + private var startupTask: Task? + private var startupDeadline: (any DebugOperationDeadline)? + private var writeDeadline: (any DebugOperationDeadline)? + private var drainDeadline: (any DebugOperationDeadline)? + private var socket: (any DebugAdapterSocketConnection)? + private var writes: [Data] = [] + private var bufferedBytes = 0 + private var activeWrite: UUID? + private var generation = UUID() + private var ready = false + private var running = false + private var draining = false + var isRunning: Bool { running && !draining } + + var onData: ((Data) -> Void)? + var onErrorOutput: ((Data) -> Void)? + var onTermination: ((Int) -> Void)? + + init( + endpointResolver: @escaping EndpointResolver, + connectionProvider: (any DebugAdapterConnectionProviding)? = nil, + deadlineScheduler: (any DebugOperationDeadlineScheduling)? = nil, + limits: Limits = Limits(), + socketFactory: (@MainActor (DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection)? = nil + ) { + precondition(limits.startupMilliseconds > 0 && limits.writeMilliseconds > 0) + precondition(limits.bufferedBytes > 0 && limits.bufferedWrites > 0) + precondition(limits.drainMilliseconds > 0) + resolveEndpoint = endpointResolver + let provider = connectionProvider ?? DirectDebugAdapterConnectionProvider() + makeSocket = socketFactory ?? { try provider.makeConnection(to: $0) } + scheduler = deadlineScheduler ?? MacDebugOperationDeadlineScheduler() + self.limits = limits + } + + isolated deinit { cleanUp() } + + func start(rootURL: URL) throws { + if draining { cleanUp() } + guard !isRunning else { return } + generation = UUID() + let token = generation + running = true + startupDeadline = scheduler.schedule(afterMilliseconds: limits.startupMilliseconds) { [weak self] in + guard let self, self.isCurrent(token), !self.ready else { return } + self.finish(error: TransportError.startupTimedOut) + } + let resolveEndpoint = resolveEndpoint + startupTask = Task { @MainActor [weak self] in + do { + let endpoint = try await resolveEndpoint(rootURL.standardizedFileURL) + try Task.checkCancellation() + guard let self, self.isCurrent(token) else { return } + // User endpoints are validated by Core before reaching this layer. + // Keep the native numeric conversion safe for every provider. + guard !endpoint.host.isEmpty, (1...65_535).contains(endpoint.port) else { + throw TransportError.invalidEndpoint + } + try self.connect(endpoint, token: token) + } catch { + guard let self, self.isCurrent(token) else { return } + self.finish(error: error) + } + } + } + + func send(_ data: Data) throws { + guard isRunning else { throw TransportError.stopped } + guard !data.isEmpty else { return } + guard writes.count < limits.bufferedWrites, + data.count <= limits.bufferedBytes - bufferedBytes else { + finish(error: TransportError.bufferLimit) + throw TransportError.bufferLimit + } + writes.append(data) + bufferedBytes += data.count + sendNext(token: generation) + } + + func stop() { cleanUp() } + + func finishSendingAndStop() { + guard running, !draining else { return } + guard ready, !writes.isEmpty else { cleanUp(); return } + draining = true + let token = generation + // The session manager can release its session immediately. The bounded + // drain owns itself until the queued disconnect is accepted or expires. + drainDeadline = scheduler.schedule(afterMilliseconds: limits.drainMilliseconds) { [self] in + guard isCurrent(token), draining else { return } + cleanUp() + } + sendNext(token: generation) + } + + private func isCurrent(_ token: UUID) -> Bool { running && generation == token } + + private func connect(_ endpoint: DebugAdapterEndpoint, token: UUID) throws { + let connection = try makeSocket(endpoint) + socket = connection + connection.onReady = { [weak self] in + guard let self, self.isCurrent(token), !self.ready else { return } + self.ready = true + self.startupDeadline?.cancel() + self.startupDeadline = nil + self.startupTask = nil + self.sendNext(token: token) + } + connection.onData = { [weak self] data in + guard let self, self.isCurrent(token), !self.draining else { return } + self.onData?(data) + } + connection.onFailure = { [weak self] error in + guard let self, self.isCurrent(token) else { return } + self.finish(error: error) + } + connection.onComplete = { [weak self] in + guard let self, self.isCurrent(token) else { return } + self.finish(error: nil) + } + connection.start() + } + + private func sendNext(token: UUID) { + guard isCurrent(token), ready, activeWrite == nil, + let data = writes.first, let socket else { return } + let writeID = UUID() + activeWrite = writeID + writeDeadline = scheduler.schedule(afterMilliseconds: limits.writeMilliseconds) { [weak self] in + guard let self, self.isCurrent(token), self.activeWrite == writeID else { return } + self.finish(error: TransportError.writeTimedOut) + } + socket.send(data) { [weak self] result in + guard let self, self.isCurrent(token), self.activeWrite == writeID else { return } + self.writeDeadline?.cancel() + self.writeDeadline = nil + self.activeWrite = nil + switch result { + case .success: + self.bufferedBytes -= self.writes.removeFirst().count + if self.draining, self.writes.isEmpty { self.cleanUp(); return } + // Do not recurse if a provider completes writes synchronously. + Task { @MainActor [weak self] in self?.sendNext(token: token) } + case .failure(let error): self.finish(error: error) + } + } + } + + private func finish(error: Error?) { + guard running else { return } + if draining { cleanUp(); return } + // Invalidate before calling native stop or client callbacks: both can reenter. + cleanUp() + let finishedGeneration = generation + if let error { onErrorOutput?(Data((error.localizedDescription + "\n").utf8)) } + // An error observer can stop or restart the owner synchronously. Never + // deliver the old termination into that replacement lifecycle. + guard generation == finishedGeneration else { return } + onTermination?(error == nil ? 0 : 1) + } + + private func cleanUp() { + generation = UUID() + running = false + draining = false + drainDeadline?.cancel() + drainDeadline = nil + startupDeadline?.cancel() + startupDeadline = nil + writeDeadline?.cancel() + writeDeadline = nil + startupTask?.cancel() + startupTask = nil + let connection = socket + socket = nil + connection?.onReady = nil + connection?.onData = nil + connection?.onFailure = nil + connection?.onComplete = nil + connection?.stop() + writes = [] + bufferedBytes = 0 + activeWrite = nil + ready = false + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index b99a4afece..b29e7d96f5 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -692,6 +692,35 @@ final class MacServiceContainer { return session } ) + adapterSessions.makeEndpointSession = { descriptor, endpoint, _ in + let entry = activeDebugAdapter(descriptor.id) + guard descriptor.id == "java" || entry != nil else { return nil } + let session = CoreDebugAdapterProtocolSession( + adapterID: entry?.adapter.adapterID ?? "java", + consoleCommands: entry?.adapter.consoleCommands ?? [], + consoleCommandPrefix: entry?.adapter.consoleCommandPrefix, + disabledCapabilities: entry?.adapter.disabledCapabilities ?? [], + transport: try MacExternalDebugAdapterTransport.make( + endpoint: endpoint, + validate: { endpoint in + do { + return try rustCore.executeResult( + command: "debug.validateAdapterEndpoint", payload: endpoint + ).get() + } catch let error as RustCoreBridge.CoreCallError where error.code == "invalid_request" { + throw MacTCPDebugAdapterTransport.TransportError.invalidEndpoint + } + } + ), + core: rustCore, + deadlineScheduler: MacDebugOperationDeadlineScheduler(), + initializationTimeoutMilliseconds: 10_000 + ) + if let entry { + debugAdapterSessionsBox.record(session, pluginID: entry.plugin.descriptor.id.rawValue) + } + return session + } debugAdapterSessionsBox.manager = adapterSessions let graph = DebugFeatureGraph( adapterSessions: adapterSessions, diff --git a/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift b/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift new file mode 100644 index 0000000000..e109baf6ed --- /dev/null +++ b/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift @@ -0,0 +1,65 @@ +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI + +struct ExternalDebugAdapterView: View { + @Environment(\.dismiss) private var dismiss + @State private var host = "127.0.0.1" + @State private var port = "" + @State private var request = DebugRequestKind.attach + @State private var arguments = "{}" + @State private var errorMessage: String? + let onConnect: (DebugLaunchConfiguration) -> Void + + private var parsedPort: Int? { + guard let value = Int(port), (1...65_535).contains(value) else { return nil } + return value + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + LitheSettingsLabel( + Text("Connect to Debug Adapter"), + info: L10n.string("Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.") + ) + .font(LitheTheme.uiFont(size: 14, weight: .semibold)) + HStack(alignment: .top, spacing: 10) { + LitheTextField(verbatim: "127.0.0.1", text: $host, label: L10n.string("Host")) + LitheTextField(verbatim: "1-65535", text: $port, label: L10n.string("Port")) + } + Picker("Request", selection: $request) { + Text("Attach Request").tag(DebugRequestKind.attach) + Text("Launch Request").tag(DebugRequestKind.launch) + } + .pickerStyle(.segmented) + LitheTextEditor( + text: $arguments, rows: 8, label: L10n.string("Adapter Arguments (JSON)"), + info: L10n.string("Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.") + ) + if let errorMessage { + Text(errorMessage).foregroundStyle(LitheTheme.error) + } + HStack(spacing: LitheTheme.ActionButton.spacing) { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + .buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) + Button("Connect") { connect() } + .buttonStyle(LitheActionButtonStyle(isPrimary: true)) + .disabled(parsedPort == nil || host.isEmpty) + } + } + .padding(18) + .frame(width: 480) + } + + private func connect() { + guard let parsedPort else { return } + do { + let configuration = try ExternalDebugAdapterConfiguration.make( + host: host, port: parsedPort, request: request, argumentsJSON: arguments + ) + onConnect(configuration) + dismiss() + } catch { errorMessage = error.localizedDescription } + } +} diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 43f831a323..da5a2eb6ee 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -23,6 +23,7 @@ struct GenericDebugView: View { @State private var smartStepTargets: [DebugStepInTarget] = [] @State private var isSmartStepPickerPresented = false @State private var isJavaAttachPresented = false + @State private var externalAdapterSource: ExternalAdapterSource? @State private var isJavaSteppingSettingsPresented = false @State private var selectedContent: DebugContent = .console @State private var consoleWrapsLines = false @@ -50,6 +51,11 @@ struct GenericDebugView: View { model.attachJavaDebugger(host: host, port: port) } } + .sheet(item: $externalAdapterSource) { source in + ExternalDebugAdapterView { configuration in + model.connectExternalDebugAdapter(configuration: configuration, fileURL: source.url) + } + } .sheet(isPresented: $isJavaSteppingSettingsPresented) { if let filters = feature.javaSteppingFilters { JavaSteppingFiltersView( @@ -398,6 +404,12 @@ struct GenericDebugView: View { LitheContextMenuItem.separator LitheContextMenuItem.action("Connect to Running JVM…") { isJavaAttachPresented = true } .disabled(feature.isSessionActive) + LitheContextMenuItem.action("Connect to Debug Adapter…") { + if let url = model.activeDocument?.url { + externalAdapterSource = ExternalAdapterSource(url: url) + } + } + .disabled(model.activeDocument?.url == nil || feature.isSessionActive) LitheContextMenuItem.action("Clear Console") { feature.clearOutput() } .disabled(!feature.hasOutput) } label: { @@ -1940,6 +1952,11 @@ private enum DebugContent: CaseIterable, Identifiable { } } +private struct ExternalAdapterSource: Identifiable { + let url: URL + var id: URL { url } +} + private struct JavaAttachView: View { @Environment(\.dismiss) private var dismiss @State private var host = "localhost" diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 3cbfa82632..383eb91706 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -20,6 +20,13 @@ public protocol DebugAdapterTransport: AnyObject { func stop() } +/// Optional bounded best-effort flush for the final DAP disconnect frame. +/// This ends the transport, never the external server; `stop` still aborts now. +@MainActor +public protocol DebugAdapterDrainingTransport: DebugAdapterTransport { + func finishSendingAndStop() +} + @MainActor public protocol DebugOperationDeadline: AnyObject { func cancel() @@ -45,22 +52,38 @@ public enum DebugRequestKind: String, Codable, Equatable, Sendable { case launch, attach } +/// IDE-to-adapter DAP endpoint, never the adapter-to-debuggee attach target. +/// Core validates it before the platform asks consent or opens a connection. +public struct DebugAdapterEndpoint: Codable, Equatable, Sendable { + public let host: String + public let port: Int + + public init(host: String, port: Int) { + self.host = host + self.port = port + } +} + public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { public let name: String public let request: DebugRequestKind public let arguments: [String: ToolingJSONValue] public let steppingFilters: DebugSteppingFilters? + /// An already running DAP server; absent means the provider's normal transport. + public let adapterEndpoint: DebugAdapterEndpoint? public init( name: String, request: DebugRequestKind, arguments: [String: ToolingJSONValue], - steppingFilters: DebugSteppingFilters? = nil + steppingFilters: DebugSteppingFilters? = nil, + adapterEndpoint: DebugAdapterEndpoint? = nil ) { self.name = name self.request = request self.arguments = arguments self.steppingFilters = steppingFilters + self.adapterEndpoint = adapterEndpoint } public func applying(steppingFilters: DebugSteppingFilters) -> Self { @@ -68,7 +91,8 @@ public struct DebugLaunchConfiguration: Codable, Equatable, Sendable { name: name, request: request, arguments: arguments, - steppingFilters: steppingFilters + steppingFilters: steppingFilters, + adapterEndpoint: adapterEndpoint ) } } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 9192cf7f5d..daa0bc4198 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -296,7 +296,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu if startAdditional(fileURL: request.fileURL, rootURL: request.rootURL, configuration: DebugLaunchConfiguration( name: request.configuration.name, request: request.configuration.request, - arguments: arguments, steppingFilters: request.configuration.steppingFilters)) { + arguments: arguments, steppingFilters: request.configuration.steppingFilters, + adapterEndpoint: request.configuration.adapterEndpoint)) { registerJavaUpdateTarget(target) } } catch { diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index 0091ed0dd5..a309c37a57 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -35,6 +35,9 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi private let disabledCapabilities: [String] private let deadlineScheduler: any DebugOperationDeadlineScheduling private let operationTimeoutMilliseconds: Int + private let initializationTimeoutMilliseconds: Int? + private var initializationDeadline: (any DebugOperationDeadline)? + private var initializationToken: UUID? private var operationHandlers: [String: PendingOperation] = [:] private var pendingRunInTerminalRequests: [String: PendingRunInTerminalRequest] = [:] private var pendingStartDebuggingRequests: [String: PendingStartDebuggingRequest] = [:] @@ -64,7 +67,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi core: any DebugProtocolCore, sessionID: String = UUID().uuidString, deadlineScheduler: any DebugOperationDeadlineScheduling, - operationTimeoutMilliseconds: Int = 10_000 + operationTimeoutMilliseconds: Int = 10_000, + initializationTimeoutMilliseconds: Int? = nil ) { self.adapterID = adapterID self.disabledCapabilities = disabledCapabilities @@ -75,6 +79,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi self.sessionID = sessionID self.deadlineScheduler = deadlineScheduler self.operationTimeoutMilliseconds = max(1, operationTimeoutMilliseconds) + self.initializationTimeoutMilliseconds = initializationTimeoutMilliseconds.map { max(1, $0) } transport.onData = { [weak self] data in self?.receive(data) } transport.onErrorOutput = { [weak self] data in self?.receiveErrorOutput(data) } transport.onTermination = { [weak self] code in self?.transportTerminated(exitCode: code) } @@ -89,6 +94,16 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi discardPendingStartDebuggingRequests() capabilities = .unknown try transport.start(rootURL: rootURL.standardizedFileURL) + if let initializationTimeoutMilliseconds { + let token = UUID() + initializationToken = token + initializationDeadline = deadlineScheduler.schedule(afterMilliseconds: initializationTimeoutMilliseconds) { [weak self] in + guard let self, self.initializationToken == token, self.ownsCoreSession else { return } + self.onEvent?(.output(category: "stderr", output: DebugAdapterProtocolError.timedOut("initialize").localizedDescription + "\n")) + guard self.initializationToken == token else { return } + self.failSession() + } + } do { let update = try core.createDebugSession( sessionID: sessionID, @@ -102,6 +117,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi ownsCoreSession = true try apply(update) } catch { + cancelInitializationDeadline() transport.stop() releaseCoreSession() state = .failed @@ -112,13 +128,18 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi public func stop() { guard state != .idle || transport.isRunning || ownsCoreSession else { return } isStopping = true + cancelInitializationDeadline() failPendingRunInTerminalRequests(DebugAdapterProtocolError.stopped) failPendingStartDebuggingRequests(DebugAdapterProtocolError.stopped) if ownsCoreSession, transport.isRunning, let update = try? core.disconnectDebugSession(sessionID: sessionID) { try? apply(update) } - transport.stop() + if let draining = transport as? any DebugAdapterDrainingTransport { + draining.finishSendingAndStop() + } else { + transport.stop() + } finishErrorOutput() releaseCoreSession() failPendingOperations(DebugAdapterProtocolError.stopped) @@ -640,6 +661,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi consume(event) } state = Self.adapterState(update.state) + if state != .initializing { cancelInitializationDeadline() } } private func consume(_ event: DebugCoreEvent) { @@ -712,6 +734,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi private func transportTerminated(exitCode: Int) { guard !isStopping else { return } + cancelInitializationDeadline() finishErrorOutput() discardPendingRunInTerminalRequests() discardPendingStartDebuggingRequests() @@ -722,6 +745,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi } private func failSession() { + cancelInitializationDeadline() transport.stop() finishErrorOutput() discardPendingRunInTerminalRequests() @@ -731,6 +755,12 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi state = .failed } + private func cancelInitializationDeadline() { + initializationToken = nil + initializationDeadline?.cancel() + initializationDeadline = nil + } + private func releaseCoreSession() { guard ownsCoreSession else { return } ownsCoreSession = false diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift index 635521a4d7..5e1381c4f0 100644 --- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift +++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -110,6 +110,12 @@ public final class DebugAdapterSessionManager: ObservableObject { } } + /// An explicitly supplied DAP endpoint bypasses the provider's process/LSP + /// transport. The platform must validate it and obtain per-connection consent. + public var makeEndpointSession: (@MainActor ( + DebugProviderDescriptor, DebugAdapterEndpoint, URL + ) throws -> (any DebugAdapterSession)?)? + /// Read at every lookup: a host that registers debug adapters while it /// runs hands back the current set instead of the one it started with. private let providersSource: @MainActor () -> [DebugProviderDescriptor] @@ -124,6 +130,7 @@ public final class DebugAdapterSessionManager: ObservableObject { let session: any DebugAdapterSession let activationToken: UUID let parentID: DebugSessionID? + let adapterEndpoint: DebugAdapterEndpoint? var targetTitle: String? } @@ -173,6 +180,7 @@ public final class DebugAdapterSessionManager: ObservableObject { let providerID = try descriptor(for: fileURL).id if let sessionID = activeSessionIDsByProvider[providerID], let managed = sessions[sessionID], + managed.adapterEndpoint == nil, managed.session.isRunning, managed.rootURL == rootURL.standardizedFileURL { return managed.session @@ -187,14 +195,24 @@ public final class DebugAdapterSessionManager: ObservableObject { /// the same provider. Existing provider-level APIs continue to use the /// latest session as their compatibility target. @discardableResult - public func activateNew(for fileURL: URL, rootURL: URL) throws -> DebugSessionHandle { + public func activateNew( + for fileURL: URL, rootURL: URL, adapterEndpoint: DebugAdapterEndpoint? = nil + ) throws -> DebugSessionHandle { let descriptor = try descriptor(for: fileURL) let normalizedRoot = rootURL.standardizedFileURL - guard let session = makeSession(descriptor, normalizedRoot) else { + let candidate: (any DebugAdapterSession)? + if let adapterEndpoint { + // Never fall back to a local process if this host lacks TCP support. + candidate = try makeEndpointSession?(descriptor, adapterEndpoint, normalizedRoot) + } else { + candidate = makeSession(descriptor, normalizedRoot) + } + guard let session = candidate else { throw DebugProviderError.adapterUnavailable(descriptor.displayName) } let (sessionID, activationToken) = register( - session, descriptor: descriptor, rootURL: normalizedRoot, parentID: nil + session, descriptor: descriptor, rootURL: normalizedRoot, parentID: nil, + adapterEndpoint: adapterEndpoint ) activeSessionIDsByProvider[descriptor.id] = sessionID roots[descriptor.id] = normalizedRoot @@ -228,6 +246,9 @@ public final class DebugAdapterSessionManager: ObservableObject { rootURL: URL, configuration: DebugLaunchConfiguration ) throws -> any DebugAdapterControllingSession { + if configuration.adapterEndpoint != nil { + return try launchNew(for: fileURL, rootURL: rootURL, configuration: configuration).session + } let session = try activate(for: fileURL, rootURL: rootURL) guard let controlling = session as? any DebugAdapterControllingSession else { throw DebugProviderError.capabilityUnavailable( @@ -246,7 +267,9 @@ public final class DebugAdapterSessionManager: ObservableObject { rootURL: URL, configuration: DebugLaunchConfiguration ) throws -> (id: DebugSessionID, session: any DebugAdapterControllingSession) { - let handle = try activateNew(for: fileURL, rootURL: rootURL) + let handle = try activateNew( + for: fileURL, rootURL: rootURL, adapterEndpoint: configuration.adapterEndpoint + ) guard let controlling = handle.session as? any DebugAdapterControllingSession else { stop(sessionID: handle.id) throw DebugProviderError.capabilityUnavailable( @@ -549,16 +572,11 @@ public final class DebugAdapterSessionManager: ObservableObject { _ session: any DebugAdapterSession, descriptor: DebugProviderDescriptor, rootURL: URL, - parentID: DebugSessionID? + parentID: DebugSessionID?, + adapterEndpoint: DebugAdapterEndpoint? = nil ) -> (DebugSessionID, UUID) { let sessionID = DebugSessionID() let activationToken = UUID() - configureCallbacks( - session, - sessionID: sessionID, - providerID: descriptor.id, - activationToken: activationToken - ) sessions[sessionID] = ManagedSession( id: sessionID, descriptor: descriptor, @@ -566,9 +584,16 @@ public final class DebugAdapterSessionManager: ObservableObject { session: session, activationToken: activationToken, parentID: parentID, + adapterEndpoint: adapterEndpoint, targetTitle: nil ) sessionOrder.append(sessionID) + configureCallbacks( + session, + sessionID: sessionID, + providerID: descriptor.id, + activationToken: activationToken + ) return (sessionID, activationToken) } @@ -577,6 +602,12 @@ public final class DebugAdapterSessionManager: ObservableObject { sessionID: DebugSessionID ) { guard let session = session as? any DebugAdapterStartDebuggingSession else { return } + // External adapters can require provider-specific child endpoints. + // Do not advertise a local-process fallback as support for those children. + if sessions[sessionID]?.adapterEndpoint != nil, makeChildSession == nil { + session.onStartDebuggingRequest = nil + return + } session.onStartDebuggingRequest = { [weak self] request, completion in guard let self else { completion(.failure(DebugAdapterProtocolError.stopped)) diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index d67657774e..c5835ca67a 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -7,6 +7,57 @@ import Testing @MainActor struct DebugModuleTests { + @Test(arguments: [DebugRequestKind.launch, .attach]) + func explicitEndpointUsesItsFactoryAndPreservesTheDAPRequest(request: DebugRequestKind) throws { + let descriptor = DebugProviderDescriptor(id: "python", displayName: "Python", fileExtensions: ["py"]) + var localCount = 0 + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + localCount += 1 + return DeferredInspectionDebugSession() + } + defer { manager.stopAll() } + let external = DeferredInspectionDebugSession() + let endpoint = DebugAdapterEndpoint(host: "debug.example.org", port: 5678) + manager.makeEndpointSession = { provider, destination, root in + #expect(provider.id == "python") + #expect(destination == endpoint) + #expect(root.path == "/workspace") + return external + } + let configuration = DebugLaunchConfiguration( + name: "Remote", request: request, arguments: ["connect": .object(["port": .integer(9000)])], + adapterEndpoint: endpoint + ) + let source = URL(fileURLWithPath: "/workspace/main.py") + _ = try manager.launch(for: source, rootURL: source.deletingLastPathComponent(), configuration: configuration) + #expect(external.launchConfigurations == [configuration]) + #expect(localCount == 0) + // A subsequent ordinary launch must not reuse the external TCP session. + _ = try manager.activate(for: source, rootURL: source.deletingLastPathComponent()) + #expect(localCount == 1) + #expect(!external.isRunning) + } + + @Test(arguments: [false, true]) + func unsupportedOrDeclinedEndpointNeverFallsBackOrRegistersASession(declined: Bool) { + let descriptor = DebugProviderDescriptor(id: "python", displayName: "Python", fileExtensions: ["py"]) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in + Issue.record("External endpoint fell back to a local process") + return nil + } + defer { manager.stopAll() } + if declined { + manager.makeEndpointSession = { _, _, _ in throw DeferredDebugSessionError.launchFailed } + } + #expect(throws: (any Error).self) { + _ = try manager.activateNew( + for: URL(fileURLWithPath: "/workspace/main.py"), rootURL: URL(fileURLWithPath: "/workspace"), + adapterEndpoint: DebugAdapterEndpoint(host: "localhost", port: 5678) + ) + } + #expect(manager.activeSessionIDs.isEmpty) + } + @Test func explicitHotUpdateRestartReplacesTheOriginalSessionWithNewRuntimePaths() async { let session = DeferredInspectionDebugSession() diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index aa321d5286..7eee1f4dae 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -429,7 +429,10 @@ private final class HostedAdapterSocket: DebugAdapterSocketConnection { private(set) var sent: [Data] = [] func start() { startCount += 1 } - func send(_ data: Data) { sent.append(data) } + func send(_ data: Data, completion: @escaping @MainActor @Sendable (Result) -> Void) { + sent.append(data) + completion(.success(())) + } func stop() { stopCount += 1 } } diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 4324270347..8a54b17c17 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -5062,7 +5062,10 @@ private final class TestDebugSocketConnection: DebugAdapterSocketConnection { private(set) var sent: [Data] = [] func start() { startCount += 1 } - func send(_ data: Data) { sent.append(data) } + func send(_ data: Data, completion: @escaping @MainActor @Sendable (Result) -> Void) { + sent.append(data) + completion(.success(())) + } func stop() { stopCount += 1 } } diff --git a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift new file mode 100644 index 0000000000..c47f9756c5 --- /dev/null +++ b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift @@ -0,0 +1,718 @@ +import Foundation +import LitheCoreContracts +import LitheDebugModule +import Network +import Testing +@testable import Lithe + +@Suite("TCP debug adapter lifecycle") +@MainActor +struct TCPDebugAdapterTests { + private let root = URL(fileURLWithPath: "/tcp-debug-test", isDirectory: true) + private let endpoint = DebugAdapterEndpoint(host: "debug.example.org", port: 5678) + + @Test + func aCancelledDrainDeadlineCannotCloseTheReplacementConnection() async throws { + let first = ControlledDebugSocket() + let second = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + var sockets = [first, second] + let transport = MacTCPDebugAdapterTransport( + endpointResolver: { _ in endpoint }, deadlineScheduler: scheduler, + socketFactory: { _ in sockets.removeFirst() } + ) + defer { transport.stop() } + try transport.start(rootURL: root) + #expect(await first.started.wait(timeout: .seconds(5))) + first.onReady?() + try transport.send(Data([1])) + transport.finishSendingAndStop() + let lateDrain = try #require(scheduler.deadlines.last).action + try transport.start(rootURL: root) + #expect(await second.started.wait(timeout: .seconds(5))) + lateDrain() + #expect(transport.isRunning) + #expect(second.stopCount == 0) + } + + @Test(arguments: [false, true]) + func gracefulStopDrainsInOrderOrClosesAtItsOwnDeadline(stalled: Bool) async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make(socket: socket, scheduler: scheduler) + defer { transport.stop() } + transport.onTermination = { _ in Issue.record("Drain notified a stopped session") } + try transport.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + socket.onReady?() + try transport.send(Data([1])) + try transport.send(Data([2])) + transport.finishSendingAndStop() + #expect(!transport.isRunning) + #expect(throws: MacTCPDebugAdapterTransport.TransportError.stopped) { try transport.send(Data([3])) } + #expect(socket.stopCount == 0) + if stalled { + scheduler.deadlines.last?.fire() + } else { + let second = TCPTestEvent() + socket.onWrite = { second.fire() } + socket.completeWrite(.success(())) + #expect(await second.wait(timeout: .seconds(5))) + #expect(socket.writes == [Data([1]), Data([2])]) + socket.completeWrite(.success(())) + } + #expect(socket.stopCount == 1) + #expect(scheduler.deadlines.allSatisfy { $0.cancelled }) + } + + @Test(arguments: [false, true]) + func aDrainReleasesItsTemporaryOwnershipOnCompletionOrTimeout(stalled: Bool) async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + var transport: MacTCPDebugAdapterTransport? = make(socket: socket, scheduler: scheduler) + weak var released = transport + defer { transport?.stop(); released?.stop() } + try transport?.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + socket.onReady?() + try transport?.send(Data([1])) + transport?.finishSendingAndStop() + transport = nil + #expect(released != nil) + if stalled { scheduler.deadlines.last?.fire() } + else { socket.completeWrite(.success(())) } + #expect(released == nil) + #expect(socket.stopCount == 1) + } + + @Test + func aConnectedNonDAPPeerCannotLeaveTheSessionInitializingForever() async throws { + let socket = ControlledDebugSocket() + let deadlines = TCPTestScheduler() + let transport = make(socket: socket) + let session = CoreDebugAdapterProtocolSession( + adapterID: "python", transport: transport, core: RustCoreBridge(), + deadlineScheduler: deadlines, initializationTimeoutMilliseconds: 100 + ) + defer { session.stop() } + try session.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + socket.onReady?() + socket.completeWrite(.success(())) + #expect(session.state == .initializing) + deadlines.deadlines[0].fire() + #expect(session.state == .failed) + #expect(!transport.isRunning) + #expect(socket.stopCount == 1) + #expect(deadlines.deadlines.allSatisfy { $0.cancelled }) + } + + @Test + func errorObserverRestartDoesNotReceiveTheOldTermination() async throws { + let first = ControlledDebugSocket() + let second = ControlledDebugSocket() + var sockets = [first, second] + let transport = MacTCPDebugAdapterTransport( + endpointResolver: { _ in endpoint }, socketFactory: { _ in sockets.removeFirst() } + ) + defer { transport.onErrorOutput = nil; transport.stop() } + transport.onErrorOutput = { _ in + do { try transport.start(rootURL: root) } + catch { Issue.record("Restart failed: \(error)") } + } + transport.onTermination = { _ in Issue.record("Old termination entered restarted lifecycle") } + try transport.start(rootURL: root) + #expect(await first.started.wait(timeout: .seconds(5))) + first.onFailure?(TCPTestError.failed) + #expect(await second.started.wait(timeout: .seconds(5))) + #expect(transport.isRunning) + } + + @Test + func releasingTheTransportCancelsItsOwnedConnection() async throws { + let socket = ControlledDebugSocket() + var transport: MacTCPDebugAdapterTransport? = make(socket: socket) + defer { transport?.stop() } + weak var released = transport + try transport?.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + transport = nil + #expect(released == nil) + #expect(socket.stopCount == 1) + #expect(socket.onReady == nil) + } + + @Test + func externalDraftKeepsEndpointOutsideDAPArgumentsAndSurvivesEncoding() throws { + let configuration = try ExternalDebugAdapterConfiguration.make( + host: endpoint.host, port: endpoint.port, request: .attach, + argumentsJSON: #"{"connect":{"host":"target.example.org","port":9000}}"# + ) + #expect(configuration.adapterEndpoint == endpoint) + #expect(configuration.arguments["adapterEndpoint"] == nil) + #expect(configuration.arguments["connect"] == .object(["host": .string("target.example.org"), "port": .integer(9000)])) + let encoded = try JSONEncoder().encode(configuration) + #expect(try JSONDecoder().decode(DebugLaunchConfiguration.self, from: encoded) == configuration) + let filters = DebugSteppingFilters(classNameFilters: [], skipSynthetics: false, + skipStaticInitializers: false, skipConstructors: false, hideFilteredStackFrames: false) + #expect(configuration.applying(steppingFilters: filters).adapterEndpoint == endpoint) + for input in ["[]", "null", "{", String(repeating: " ", count: ExternalDebugAdapterConfiguration.maximumArgumentBytes + 1)] { + #expect(throws: ExternalDebugAdapterConfiguration.ValidationError.self) { + _ = try ExternalDebugAdapterConfiguration.make(host: "localhost", port: 5678, request: .launch, argumentsJSON: input) + } + } + } + + @Test(arguments: [false, true]) + func explicitStopCancelsResolutionOrConnectionWithoutTerminalCallbacks(connecting: Bool) async throws { + let gate = TCPEndpointGate() + let socket = ControlledDebugSocket() + let transport = MacTCPDebugAdapterTransport( + endpointResolver: { _ in connecting ? endpoint : try await gate.resolve() }, + socketFactory: { _ in socket } + ) + defer { transport.stop(); gate.finish(endpoint) } + transport.onTermination = { _ in Issue.record("Explicit stop notified termination") } + try transport.start(rootURL: root) + if connecting { #expect(await socket.started.wait(timeout: .seconds(5))) } + else { #expect(await gate.requested.wait(timeout: .seconds(5))) } + let lateReady = socket.onReady + transport.stop() + lateReady?() + if !connecting { + gate.finish(endpoint) + #expect(await gate.returned.wait(timeout: .seconds(5))) + } + #expect(!transport.isRunning) + #expect(socket.stopCount == (connecting ? 1 : 0)) + } + + @Test + func connectionProviderFailureEndsOnceAndCancelsStartup() async throws { + let scheduler = TCPTestScheduler() + let ended = TCPTestEvent() + let transport = MacTCPDebugAdapterTransport( + endpointResolver: { _ in endpoint }, deadlineScheduler: scheduler, + socketFactory: { _ in throw TCPTestError.failed } + ) + defer { transport.stop() } + var codes: [Int] = [] + transport.onTermination = { codes.append($0); ended.fire() } + try transport.start(rootURL: root) + #expect(await ended.wait(timeout: .seconds(5))) + #expect(codes == [1]) + #expect(!transport.isRunning) + #expect(scheduler.deadlines.allSatisfy { $0.cancelled }) + } + + @Test + func loopbackEndpointNeedsNoRemoteApproval() async throws { + let provider = TCPTestConnectionProvider() + let transport = try MacExternalDebugAdapterTransport.make( + endpoint: .init(host: "127.0.0.1", port: 5678), + validate: { .init(host: $0.host, port: $0.port, remote: false) }, + confirm: { _ in Issue.record("Loopback prompted for remote consent"); return false }, + connectionProvider: provider + ) + defer { transport.stop() } + try transport.start(rootURL: root) + #expect(await provider.socket.started.wait(timeout: .seconds(5))) + #expect(provider.endpoints == [.init(host: "127.0.0.1", port: 5678)]) + } + + @Test + func queuesInOrderAndKeepsOnlyOneNativeWriteInFlight() async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make(socket: socket, scheduler: scheduler) + defer { transport.stop() } + try transport.start(rootURL: root) + try transport.send(Data([1])) + try transport.send(Data([2])) + #expect(await socket.started.wait(timeout: .seconds(5))) + #expect(socket.writes.isEmpty) + socket.onReady?() + #expect(socket.writes == [Data([1])]) + #expect(scheduler.deadlines[0].cancelled) + let secondWrite = TCPTestEvent() + socket.onWrite = { secondWrite.fire() } + socket.completeWrite(.success(())) + #expect(await secondWrite.wait(timeout: .seconds(5))) + #expect(socket.writes == [Data([1]), Data([2])]) + socket.completeWrite(.success(())) + #expect(scheduler.deadlines.allSatisfy { $0.cancelled }) + } + + @Test(arguments: [false, true]) + func bufferLimitIncludesQueuedAndInFlightBytes(ready: Bool) async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make(socket: socket, scheduler: scheduler, + limits: .init(bufferedBytes: 3, bufferedWrites: 2)) + var codes: [Int] = [] + transport.onTermination = { codes.append($0) } + defer { transport.stop() } + try transport.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + if ready { socket.onReady?() } + try transport.send(Data([1, 2])) + try transport.send(Data([3])) + #expect(throws: MacTCPDebugAdapterTransport.TransportError.bufferLimit) { + try transport.send(Data([4])) + } + #expect(codes == [1]) + #expect(socket.stopCount == 1) + #expect(!transport.isRunning) + #expect(scheduler.deadlines.allSatisfy { $0.cancelled }) + } + + @Test + func writeCountIsBoundedEvenForSmallMessages() async throws { + let socket = ControlledDebugSocket() + let transport = make(socket: socket, limits: .init(bufferedBytes: 100, bufferedWrites: 1)) + defer { transport.stop() } + try transport.start(rootURL: root) + try transport.send(Data([1])) + #expect(throws: MacTCPDebugAdapterTransport.TransportError.bufferLimit) { + try transport.send(Data([2])) + } + #expect(!transport.isRunning) + } + + @Test + func startupDeadlineCancelsResolutionAndRejectsItsLateEndpoint() async throws { + let gate = TCPEndpointGate() + let scheduler = TCPTestScheduler() + var opened = 0 + let transport = MacTCPDebugAdapterTransport( + endpointResolver: { _ in try await gate.resolve() }, + deadlineScheduler: scheduler, + socketFactory: { _ in opened += 1; return ControlledDebugSocket() } + ) + defer { transport.stop(); gate.finish(endpoint) } + var codes: [Int] = [] + transport.onTermination = { codes.append($0) } + try transport.start(rootURL: root) + #expect(await gate.requested.wait(timeout: .seconds(5))) + scheduler.deadlines[0].fire() + gate.finish(endpoint) + #expect(await gate.returned.wait(timeout: .seconds(5))) + #expect(opened == 0) + #expect(codes == [1]) + #expect(!transport.isRunning) + } + + @Test + func connectionAndWriteDeadlinesDoNotLimitAnIdleConnectedSession() async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make(socket: socket, scheduler: scheduler) + defer { transport.stop() } + var codes: [Int] = [] + transport.onTermination = { codes.append($0) } + try transport.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + let expiredStartup = scheduler.deadlines[0].action + socket.onReady?() + expiredStartup() + #expect(transport.isRunning) + #expect(scheduler.deadlines.count == 1) + try transport.send(Data([1])) + scheduler.deadlines[1].fire() + #expect(codes == [1]) + #expect(socket.stopCount == 1) + } + + @Test + func aSocketThatNeverBecomesReadyHitsTheStartupDeadline() async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make(socket: socket, scheduler: scheduler) + defer { transport.stop() } + try transport.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + scheduler.deadlines[0].fire() + #expect(!transport.isRunning) + #expect(socket.stopCount == 1) + } + + @Test(arguments: ["eof", "read", "write"]) + func terminalEventsCleanUpExactlyOnceAndRevokeCallbacks(kind: String) async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make(socket: socket, scheduler: scheduler) + defer { transport.stop() } + var codes: [Int] = [] + var received = Data() + transport.onTermination = { codes.append($0) } + transport.onData = { received.append($0) } + try transport.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + socket.onReady?() + let lateReady = socket.onReady + let lateData = socket.onData + let lateError = socket.onFailure + let lateEOF = socket.onComplete + socket.onData?(Data([7])) + switch kind { + case "eof": socket.onComplete?() + case "read": socket.onFailure?(TCPTestError.failed) + default: + try transport.send(Data([1])) + socket.completeWrite(.failure(TCPTestError.failed)) + } + lateReady?() + lateData?(Data([8])) + lateError?(TCPTestError.failed) + lateEOF?() + transport.stop() + transport.stop() + #expect(codes == [kind == "eof" ? 0 : 1]) + #expect(received == Data([7])) + #expect(socket.stopCount == 1) + #expect(socket.onData == nil && socket.onFailure == nil && socket.onReady == nil && socket.onComplete == nil) + #expect(scheduler.deadlines.allSatisfy { $0.cancelled }) + } + + @Test + func restartRejectsOldSocketCallbacksAndWriteCompletions() async throws { + let first = ControlledDebugSocket() + let second = ControlledDebugSocket() + var sockets = [first, second] + let transport = MacTCPDebugAdapterTransport( + endpointResolver: { _ in endpoint }, + socketFactory: { _ in sockets.removeFirst() } + ) + defer { transport.stop() } + var received = Data() + var codes: [Int] = [] + transport.onData = { received.append($0) } + transport.onTermination = { codes.append($0) } + try transport.start(rootURL: root) + #expect(await first.started.wait(timeout: .seconds(5))) + first.onReady?() + try transport.send(Data([1])) + let oldData = first.onData + let oldFailure = first.onFailure + let oldReady = first.onReady + transport.stop() + try transport.start(rootURL: root) + #expect(await second.started.wait(timeout: .seconds(5))) + second.onReady?() + try transport.send(Data([2])) + first.completeWrite(.failure(TCPTestError.failed)) + oldData?(Data([9])) + oldFailure?(TCPTestError.failed) + oldReady?() + second.onData?(Data([3])) + #expect(received == Data([3])) + #expect(codes.isEmpty) + #expect(second.writes == [Data([2])]) + #expect(transport.isRunning) + } + + @Test(arguments: [false, true]) + func externalEndpointIsValidatedAndConfirmedBeforeAnyConnection(approved: Bool) async throws { + let provider = TCPTestConnectionProvider() + var order: [String] = [] + let makeTransport = { + try MacExternalDebugAdapterTransport.make( + endpoint: endpoint, + validate: { input in + order.append("validate") + #expect(input == endpoint) + return .init(host: input.host, port: input.port, remote: true) + }, + confirm: { input in + order.append("confirm") + #expect(input == endpoint) + return approved + }, + connectionProvider: provider + ) + } + if approved { + let transport = try makeTransport() + defer { transport.stop() } + try transport.start(rootURL: root) + #expect(await provider.socket.started.wait(timeout: .seconds(5))) + #expect(provider.endpoints == [endpoint]) + transport.stop() + #expect(provider.socket.stopCount == 1) + } else { + #expect(throws: MacTCPDebugAdapterTransport.TransportError.connectionDeclined) { + _ = try makeTransport() + } + #expect(provider.endpoints.isEmpty) + } + #expect(order == ["validate", "confirm"]) + } + + @Test + func anInvalidExternalEndpointNeverAsksConsentOrOpensASocket() throws { + let provider = TCPTestConnectionProvider() + #expect(throws: TCPTestError.self) { + _ = try MacExternalDebugAdapterTransport.make( + endpoint: endpoint, + validate: { _ in throw TCPTestError.failed }, + confirm: { _ in Issue.record("Invalid endpoint reached consent"); return true }, + connectionProvider: provider + ) + } + #expect(provider.endpoints.isEmpty) + } + + @Test + func realTCPStreamsBytesBothWaysAndStopDoesNotStopTheExternalListener() async throws { + let server = try TCPTestServer() + defer { server.stop() } + #expect(await server.ready.wait(timeout: .seconds(5))) + let port = try #require(server.port) + let transport = MacTCPDebugAdapterTransport(endpointResolver: { _ in + DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) + }) + defer { transport.stop() } + let received = TCPTestEvent() + var bytes = Data() + transport.onData = { bytes.append($0); if bytes.count >= 4 { received.fire() } } + try transport.start(rootURL: root) + try transport.send(Data([1, 2, 3, 4])) + #expect(await received.wait(timeout: .seconds(5))) + #expect(bytes == Data([1, 2, 3, 4])) + transport.stop() + #expect(server.isReady) + #expect(!transport.isRunning) + } + + @Test + func realTCPRunsCoreInitializeAndAttachWithFragmentedDAPResponses() async throws { + let server = try TCPTestServer() + defer { server.stop() } + #expect(await server.ready.wait(timeout: .seconds(5))) + let port = try #require(server.port) + let transport = MacTCPDebugAdapterTransport(endpointResolver: { _ in + DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) + }) + defer { transport.stop() } + let session = CoreDebugAdapterProtocolSession( + adapterID: "python", transport: transport, core: RustCoreBridge(), + deadlineScheduler: MacDebugOperationDeadlineScheduler() + ) + defer { session.stop() } + let attached = TCPTestEvent() + let disconnected = TCPTestEvent() + var commands: [String] = [] + var incoming = Data() + server.onData = { data, connection in + incoming.append(data) + while let boundary = incoming.range(of: Data("\r\n\r\n".utf8)), + let header = String(data: incoming[..= boundary.upperBound + length { + let body = incoming[boundary.upperBound..<(boundary.upperBound + length)] + incoming = Data(incoming[(boundary.upperBound + length)...]) + guard let request = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let command = request["command"] as? String, let sequence = request["seq"] as? Int else { + Issue.record("Malformed DAP request"); return + } + commands.append(command) + if command == "attach" { + let args = request["arguments"] as? [String: Any] + #expect(args?["justMyCode"] as? Bool == true) + #expect(args?["adapterEndpoint"] == nil) + } + if command == "disconnect" { + let args = request["arguments"] as? [String: Any] + #expect(args?["terminateDebuggee"] as? Bool == false) + disconnected.fire() + } + let response: [String: Any] = ["seq": sequence + 100, "type": "response", "request_seq": sequence, + "command": command, "success": true, "body": [:]] + guard let payload = try? JSONSerialization.data(withJSONObject: response) else { return } + // Exercise Core's incremental framing through the real socket. + connection.send(content: Data("Content-Length: \(payload.count)\r\n".utf8), completion: .contentProcessed { _ in }) + connection.send(content: Data("\r\n".utf8) + payload, completion: .contentProcessed { _ in }) + if command == "initialize" { + let event = Data(#"{"seq":200,"type":"event","event":"initialized"}"#.utf8) + connection.send(content: Data("Content-Length: \(event.count)\r\n\r\n".utf8) + event, completion: .contentProcessed { _ in }) + } + } + } + session.onStateChange = { state in if state == .running { attached.fire() } } + try session.start(rootURL: root) + try session.launch(.init(name: "TCP", request: .attach, arguments: ["justMyCode": .bool(true)], + adapterEndpoint: .init(host: "127.0.0.1", port: Int(port)))) + #expect(await attached.wait(timeout: .seconds(5))) + #expect(commands.first == "initialize") + #expect(commands.contains("attach")) + #expect(!commands.contains("launch")) + session.stop() + #expect(await disconnected.wait(timeout: .seconds(5))) + #expect(!transport.isRunning) + #expect(server.isReady) + } + + private func make( + socket: ControlledDebugSocket, + scheduler: TCPTestScheduler? = nil, + limits: MacTCPDebugAdapterTransport.Limits = .init() + ) -> MacTCPDebugAdapterTransport { + MacTCPDebugAdapterTransport( + endpointResolver: { _ in endpoint }, deadlineScheduler: scheduler, + limits: limits, socketFactory: { _ in socket } + ) + } +} + +private enum TCPTestError: Error { case failed } + +@MainActor +private final class ControlledDebugSocket: DebugAdapterSocketConnection { + var onReady: (() -> Void)? + var onData: ((Data) -> Void)? + var onFailure: ((Error) -> Void)? + var onComplete: (() -> Void)? + var onWrite: (() -> Void)? + let started = TCPTestEvent() + private(set) var stopCount = 0 + private(set) var writes: [Data] = [] + private var completions: [@MainActor @Sendable (Result) -> Void] = [] + func start() { started.fire() } + func send(_ data: Data, completion: @escaping @MainActor @Sendable (Result) -> Void) { + writes.append(data) + completions.append(completion) + onWrite?() + } + func completeWrite(_ result: Result) { + guard !completions.isEmpty else { Issue.record("No pending socket write"); return } + completions.removeFirst()(result) + } + func stop() { stopCount += 1 } +} + +@MainActor +private final class TCPTestConnectionProvider: DebugAdapterConnectionProviding { + let socket = ControlledDebugSocket() + var endpoints: [DebugAdapterEndpoint] = [] + func makeConnection(to endpoint: DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection { + endpoints.append(endpoint) + return socket + } +} + +@MainActor +private final class TCPTestScheduler: DebugOperationDeadlineScheduling { + var deadlines: [TCPTestDeadline] = [] + func schedule(afterMilliseconds _: Int, action: @escaping @MainActor () -> Void) -> any DebugOperationDeadline { + let deadline = TCPTestDeadline(action: action) + deadlines.append(deadline) + return deadline + } +} + +@MainActor +private final class TCPTestDeadline: DebugOperationDeadline { + var action: @MainActor () -> Void + var cancelled = false + init(action: @escaping @MainActor () -> Void) { self.action = action } + func cancel() { cancelled = true; action = {} } + func fire() { if !cancelled { action() } } +} + +/// A monotonic local deadline only bounds failure; the observed event wakes success. +@MainActor +private final class TCPTestEvent { + private var fired = false + private var waiter: CheckedContinuation? + private var timeoutItem: DispatchWorkItem? + func fire() { fired = true; finish(true) } + func wait(timeout: DispatchTimeInterval) async -> Bool { + if fired { return true } + return await withCheckedContinuation { continuation in + waiter = continuation + let item = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.finish(false) } + } + timeoutItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: item) + } + } + private func finish(_ result: Bool) { + timeoutItem?.cancel() + timeoutItem = nil + let pending = waiter + waiter = nil + pending?.resume(returning: result) + } +} + +@MainActor +private final class TCPEndpointGate { + let requested = TCPTestEvent() + let returned = TCPTestEvent() + private var pending: CheckedContinuation? + func resolve() async throws -> DebugAdapterEndpoint { + defer { returned.fire() } + return try await withCheckedThrowingContinuation { continuation in + pending = continuation + requested.fire() + } + } + func finish(_ endpoint: DebugAdapterEndpoint) { + let continuation = pending + pending = nil + continuation?.resume(returning: endpoint) + } +} + +/// Real loopback integration uses an OS-assigned port, no external service or sleep. +@MainActor +private final class TCPTestServer { + let listener: NWListener + let ready = TCPTestEvent() + private(set) var isReady = false + private var stopped = false + var onData: ((Data, NWConnection) -> Void)? + var port: UInt16? { listener.port?.rawValue } + private var connections: [NWConnection] = [] + private let queue = DispatchQueue(label: "app.lithe.test.dap-tcp") + init() throws { + listener = try NWListener(using: .tcp, on: .any) + listener.stateUpdateHandler = { [weak self] state in + Task { @MainActor [weak self] in + guard let self, !self.stopped else { return } + if case .ready = state { self.isReady = true; self.ready.fire() } + } + } + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor [weak self] in + guard let self, !self.stopped else { connection.cancel(); return } + self.connections.append(connection) + connection.start(queue: self.queue) + self.echo(connection) + } + } + listener.start(queue: queue) + } + func stop() { + stopped = true + onData = nil + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + connections.forEach { $0.cancel() } + connections = [] + } + private func echo(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1024) { [weak self] data, _, complete, error in + Task { @MainActor [weak self] in + guard let self, !self.stopped else { return } + if let data { + if let onData = self.onData { onData(data, connection) } + else { connection.send(content: data, completion: .contentProcessed { _ in }) } + } + if !complete, error == nil { self.echo(connection) } + } + } + } +} diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 9f19579768..39e7633a17 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -1741,7 +1741,7 @@ fn connection_endpoint(target: &TargetFacts) -> Validation { /// name or an IP literal, without scheme, credentials, port, path, brackets, /// or whitespace. Non-ASCII names are written in punycode; an IPv6 zone is not /// supported. -fn host_problem(host: &str) -> Option<&'static str> { +pub(super) fn host_problem(host: &str) -> Option<&'static str> { if host.is_empty() || host.len() > MAX_HOST { return Some("A host is 1-253 characters."); } @@ -1774,7 +1774,7 @@ fn host_problem(host: &str) -> Option<&'static str> { } /// Whether Core can tell from the text alone that the host is this machine. -fn is_loopback_host(host: &str) -> bool { +pub(super) fn is_loopback_host(host: &str) -> bool { host.eq_ignore_ascii_case("localhost") || host .parse::() diff --git a/rust/lithe-core/src/debug/endpoint.rs b/rust/lithe-core/src/debug/endpoint.rs new file mode 100644 index 0000000000..e313c504aa --- /dev/null +++ b/rust/lithe-core/src/debug/endpoint.rs @@ -0,0 +1,60 @@ +//! Validation for the IDE-to-adapter endpoint, separate from a debuggee target. + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::protocol::{CoreError, ErrorCode}; + +/// Checks a user-selected DAP endpoint without DNS, sockets or implied consent. +/// Unknown names are remote even if they later resolve to a loopback address. +pub(crate) fn validate_adapter_endpoint(payload: Value) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Endpoint { + host: String, + port: u16, + } + let endpoint: Endpoint = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid DAP endpoint.") + .with_details(error.to_string()) + })?; + if let Some(problem) = super::adapters::host_problem(&endpoint.host) { + return Err(CoreError::new(ErrorCode::InvalidRequest, problem)); + } + if endpoint.port == 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A port is 1-65535.", + )); + } + Ok(json!({ + "host": endpoint.host, + "port": endpoint.port, + "remote": !super::adapters::is_loopback_host(&endpoint.host), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dap_endpoint_contract_fixtures_match_validation_and_consent_classification() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/adapter-endpoints-v1.json" + ))) + .expect("endpoint fixture is JSON"); + for case in fixture["cases"].as_array().expect("fixture cases") { + let result = validate_adapter_endpoint(case["input"].clone()); + if let Some(remote) = case.get("remote") { + let endpoint = result.expect("valid endpoint"); + assert_eq!(endpoint["remote"], *remote, "{case}"); + assert_eq!(endpoint["host"], case["input"]["host"], "{case}"); + assert_eq!(endpoint["port"], case["input"]["port"], "{case}"); + } else { + assert!(result.is_err(), "{case}"); + } + } + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index 035d067a31..c49020ab9a 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod adapters; mod breakpoint_relocation; mod capabilities; +mod endpoint; mod engine; mod java_test; mod projection_v2; @@ -10,6 +11,7 @@ mod protocol; mod types; pub(crate) use breakpoint_relocation::*; +pub(crate) use endpoint::*; pub(crate) use engine::*; pub(crate) use java_test::*; pub(crate) use types::*; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index de41103e2b..3f43a60069 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -84,6 +84,8 @@ pub enum CoreCommand { PluginsDeclarativeLanguageServerLaunchPlan, /// Checks a debug start and returns the adapter's DAP arguments (`debug.adapters.startArguments`). DebugAdaptersStartArguments, + /// Validates an IDE-to-DAP TCP endpoint, not an attach target. + DebugValidateAdapterEndpoint, /// Says where to look for the program a launch debugs (`debug.adapters.executablePlan`). DebugAdaptersExecutablePlan, /// Compares a launch runtime's version output with its requirement (`plugins.declarative.checkRuntimeVersion`). @@ -509,6 +511,7 @@ impl CoreCommand { Some(Self::PluginsDeclarativeLanguageServerLaunchPlan) } "debug.adapters.startArguments" => Some(Self::DebugAdaptersStartArguments), + "debug.validateAdapterEndpoint" => Some(Self::DebugValidateAdapterEndpoint), "debug.adapters.executablePlan" => Some(Self::DebugAdaptersExecutablePlan), "plugins.declarative.checkRuntimeVersion" => { Some(Self::PluginsDeclarativeCheckRuntimeVersion) diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 595245c85a..e74f189064 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -213,6 +213,12 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::plugins::declarative::language_server_launch_plan_command, ), + CoreCommand::DebugValidateAdapterEndpoint => { + match crate::debug::validate_adapter_endpoint(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugAdaptersStartArguments => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index b9f3a0a8e0..3719056ad1 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -3,6 +3,29 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde_json::Value; +#[test] +fn debug_adapter_endpoint_validation_crosses_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/adapter-endpoints-v1.json" + ))) + .expect("endpoint fixture is JSON"); + for case in fixture["cases"].as_array().expect("endpoint cases") { + let request = serde_json::json!({ + "id": "validate-endpoint", "command": "debug.validateAdapterEndpoint", "payload": case["input"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("endpoint response is JSON"); + assert_eq!(response["id"], "validate-endpoint"); + assert_eq!(response["ok"], case.get("remote").is_some(), "{case}"); + if let Some(remote) = case.get("remote") { + assert_eq!(response["data"]["remote"], *remote, "{case}"); + } else { + assert_eq!(response["error"]["code"], "invalid_request", "{case}"); + } + } +} + #[test] fn ping_exposes_protocol_version() { let response: Value = serde_json::from_str(&execute_json( diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2e7b2ee49a..f2d956a480 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -977,6 +977,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | | `maven.testResults` | Parse bounded JUnit/Surefire result summaries and failure locations | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | +| `debug.validateAdapterEndpoint` | Validate an IDE-to-DAP TCP endpoint and classify whether remote consent is required; performs no DNS or network I/O | | `debug.launch` | Queue a launch or attach request, including during initialization | | `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | | `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | @@ -2090,6 +2091,32 @@ scope, variable, evaluation, output, stop, continue, and termination events. Platforms own adapter discovery, JDT LS activation, sockets or process pipes, native process termination, persistence, and UI rendering. +`debug.validateAdapterEndpoint` accepts exactly `{ host, port }`. `host` is a +bare IPv4, unbracketed IPv6, or DNS name, not a URL, authority, credential, +path, or scoped address; `port` is an integer from 1 through 65535. It returns +`{ host, port, remote }`, preserving the input. `localhost` (case insensitive), +127.0.0.0/8 and `::1` are loopback; every other spelling requires remote +consent, without resolving DNS. Invalid inputs return `invalid_request`. +Compatibility cases live in `shared/fixtures/debug/adapter-endpoints-v1.json`. +The platform obtains approval before connecting a non-loopback endpoint, once +per connection with no remembered approval, and warns that plain TCP provides +neither authentication nor encryption. This endpoint is the **IDE-to-adapter** +address, not a `targetBindings.connection` debuggee address. + +The macOS launch envelope may carry optional `adapterEndpoint: { host, port }` +beside `arguments`. Its session manager selects an already-running DAP server +instead of starting a local adapter. Missing platform support fails without a +local fallback. This host-only field is not forwarded as DAP arguments: Core's +launch configuration ignores it. `request` still independently chooses `launch` +or `attach`. The provider's language-server port source remains loopback-only. +Closing this connection never stops the external server, shared language server, +debuggee, or user-managed tunnel. On user stop, the macOS TCP host makes a +bounded best-effort drain of queued frames (including disconnect) for up to one +second before closing; this is local write completion, not a remote acknowledgement. +Failure and explicit transport abort close immediately. There is no automatic reconnect or request +replay. Proxy/tunnel implementations and plugin `tcpPort` / `tcpAnnounced` +process hosting are not enabled by this API. + `debug.createSession` accepts `{ sessionId, adapterId, rootPath, supportsRunInTerminalRequest, disabledCapabilities? }`. `disabledCapabilities` carries the plugin adapter's `capabilities.disabled` diff --git a/shared/fixtures/debug/adapter-endpoints-v1.json b/shared/fixtures/debug/adapter-endpoints-v1.json new file mode 100644 index 0000000000..7525d809f3 --- /dev/null +++ b/shared/fixtures/debug/adapter-endpoints-v1.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "cases": [ + { "input": { "host": "127.0.0.1", "port": 5678 }, "remote": false }, + { "input": { "host": "127.8.9.10", "port": 1 }, "remote": false }, + { "input": { "host": "LocalHost", "port": 65535 }, "remote": false }, + { "input": { "host": "::1", "port": 5678 }, "remote": false }, + { "input": { "host": "2001:db8::1", "port": 5678 }, "remote": true }, + { "input": { "host": "::ffff:127.0.0.1", "port": 5678 }, "remote": true }, + { "input": { "host": "debug.example.org", "port": 5678 }, "remote": true }, + { "input": { "host": "localhost.example.org", "port": 5678 }, "remote": true }, + { "input": { "host": "10.0.0.5", "port": 5678 }, "remote": true }, + { "input": { "host": "0.0.0.0", "port": 5678 }, "remote": true }, + { "input": { "host": "", "port": 5678 } }, + { "input": { "host": "host", "port": 0 } }, + { "input": { "host": "host", "port": 65536 } }, + { "input": { "host": "host", "port": -1 } }, + { "input": { "host": "host", "port": 1.5 } }, + { "input": { "host": "host", "port": "5678" } }, + { "input": { "host": "user@host", "port": 5678 } }, + { "input": { "host": "http://host", "port": 5678 } }, + { "input": { "host": "[::1]", "port": 5678 } }, + { "input": { "host": "fe80::1%en0", "port": 5678 } }, + { "input": { "host": "host:5678", "port": 5678 } }, + { "input": { "host": "host/path", "port": 5678 } }, + { "input": { "host": "host name", "port": 5678 } }, + { "input": { "host": "-host", "port": 5678 } }, + { "input": { "host": "1.2.3", "port": 5678 } }, + { "input": { "host": "host", "port": 5678, "proxy": "unsupported" } }, + { "input": { "port": 5678 } }, + { "input": { "host": "host" } } + ] +} diff --git a/shared/fixtures/debug/v1-baseline.json b/shared/fixtures/debug/v1-baseline.json index e3640aa51f..1fe2f48430 100644 --- a/shared/fixtures/debug/v1-baseline.json +++ b/shared/fixtures/debug/v1-baseline.json @@ -456,6 +456,30 @@ } ], "gaps": [] + }, + { + "id": "adapter-tcp-endpoint", + "behavior": "IDE-to-DAP endpoints are validated independently of debuggee attach arguments; TCP startup/write waits, buffering, cancellation and ownership are bounded", + "status": "covered", + "evidence": [ + { + "platform": "rust", + "path": "rust/lithe-core/src/debug/endpoint.rs", + "test": "dap_endpoint_contract_fixtures_match_validation_and_consent_classification", + "fixture": "shared/fixtures/debug/adapter-endpoints-v1.json" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheTests/TCPDebugAdapterTests.swift", + "test": "realTCPRunsCoreInitializeAndAttachWithFragmentedDAPResponses" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "test": "explicitEndpointUsesItsFactoryAndPreservesTheDAPRequest" + } + ], + "gaps": [] } ] } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 0f56a60b13..a3c3f91f0f 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9371,7 +9371,24 @@ "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", + "debug.tcp.startupTimedOut": "The debug adapter connection timed out.", + "debug.tcp.writeTimedOut": "The debug adapter did not accept outgoing data in time.", + "debug.tcp.bufferFull": "The debug adapter outgoing buffer is full.", + "debug.tcp.invalidEndpoint": "Enter a valid debug adapter host and a port between 1 and 65535.", + "debug.tcp.declined": "The debug adapter connection was not approved.", + "debug.tcp.confirmTitle": "Connect to an external debug adapter?", + "debug.tcp.confirmBody": "The debug adapter at {arg1} can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured.", + "debug.tcp.menu": "Connect to Debug Adapter…", + "debug.tcp.title": "Connect to Debug Adapter", + "debug.tcp.name": "External Debug Adapter", + "debug.tcp.help": "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.", + "debug.tcp.request": "Request", + "debug.tcp.arguments": "Adapter Arguments (JSON)", + "debug.tcp.argumentsHelp": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", + "debug.tcp.invalidArguments": "Enter a JSON object of adapter arguments no larger than 256 KiB.", + "debug.tcp.attachRequest": "Attach Request", + "debug.tcp.launchRequest": "Launch Request" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index e9db5ee591..2ae2a78697 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9371,7 +9371,24 @@ "native.openLocalFolder": "本地文件夹", "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", - "native.thisFolderIsEmpty": "此文件夹为空" + "native.thisFolderIsEmpty": "此文件夹为空", + "debug.tcp.startupTimedOut": "调试适配器连接超时。", + "debug.tcp.writeTimedOut": "调试适配器未能及时接收待发送数据。", + "debug.tcp.bufferFull": "调试适配器的发送缓冲区已满。", + "debug.tcp.invalidEndpoint": "请输入有效的调试适配器主机和 1 到 65535 之间的端口。", + "debug.tcp.declined": "未批准连接调试适配器。", + "debug.tcp.confirmTitle": "连接到外部调试适配器?", + "debug.tcp.confirmBody": "位于 {arg1} 的调试适配器可以访问你的调试数据。普通 TCP 不提供加密或身份认证。请仅通过可信网络或你自行配置的隧道连接。", + "debug.tcp.menu": "连接调试适配器…", + "debug.tcp.title": "连接调试适配器", + "debug.tcp.name": "外部调试适配器", + "debug.tcp.help": "使用当前文件已安装的调试器连接已运行的 DAP 服务。这不是 JVM JDWP 或 gdbserver 端口。不会创建代理或隧道。", + "debug.tcp.request": "请求", + "debug.tcp.arguments": "适配器参数(JSON)", + "debug.tcp.argumentsHelp": "输入此适配器所需的启动或附加参数,必要时包含源码路径映射。这些参数独立于 DAP 服务的 TCP 地址。", + "debug.tcp.invalidArguments": "请输入不超过 256 KiB 的适配器参数 JSON 对象。", + "debug.tcp.attachRequest": "附加请求", + "debug.tcp.launchRequest": "启动请求" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 30a9695c2c..0d2c32cd3a 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89187,6 +89187,198 @@ "parameters": { "arg1": "string" } + }, + { + "id": "debug.tcp.startupTimedOut", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter connection timed out.", + "arguments": [] + } + }, + { + "id": "debug.tcp.writeTimedOut", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter did not accept outgoing data in time.", + "arguments": [] + } + }, + { + "id": "debug.tcp.bufferFull", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter outgoing buffer is full.", + "arguments": [] + } + }, + { + "id": "debug.tcp.invalidEndpoint", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a valid debug adapter host and a port between 1 and 65535.", + "arguments": [] + } + }, + { + "id": "debug.tcp.declined", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter connection was not approved.", + "arguments": [] + } + }, + { + "id": "debug.tcp.confirmTitle", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to an external debug adapter?", + "arguments": [] + } + }, + { + "id": "debug.tcp.confirmBody", + "description": "Debug adapter TCP lifecycle and connection consent", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "debug.tcp.menu", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to Debug Adapter…", + "arguments": [] + } + }, + { + "id": "debug.tcp.title", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to Debug Adapter", + "arguments": [] + } + }, + { + "id": "debug.tcp.name", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "External Debug Adapter", + "arguments": [] + } + }, + { + "id": "debug.tcp.help", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connect to an already running DAP server using the active file's installed debugger. This is not a JVM JDWP or gdbserver port. No proxy or tunnel is created.", + "arguments": [] + } + }, + { + "id": "debug.tcp.request", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Request", + "arguments": [] + } + }, + { + "id": "debug.tcp.arguments", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Adapter Arguments (JSON)", + "arguments": [] + } + }, + { + "id": "debug.tcp.argumentsHelp", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", + "arguments": [] + } + }, + { + "id": "debug.tcp.invalidArguments", + "description": "External DAP TCP connection dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a JSON object of adapter arguments no larger than 256 KiB.", + "arguments": [] + } + }, + { + "id": "debug.tcp.attachRequest", + "description": "External DAP request kind", + "platforms": [ + "macos" + ], + "macos": { + "key": "Attach Request", + "arguments": [] + } + }, + { + "id": "debug.tcp.launchRequest", + "description": "External DAP request kind", + "platforms": [ + "macos" + ], + "macos": { + "key": "Launch Request", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/debug-tcp-transport.json b/shared/platform-feature-matrix/features/debug-tcp-transport.json new file mode 100644 index 0000000000..1cf74f6cfe --- /dev/null +++ b/shared/platform-feature-matrix/features/debug-tcp-transport.json @@ -0,0 +1,33 @@ +{ + "id": "debug-tcp-transport", + "area": "运行与调试", + "group": "调试器", + "capability": "普通 TCP 调试适配器连接与有界生命周期", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift", + "macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift", + "macos/Sources/Lithe/Application/Features/ExternalDebugAdapterConfiguration.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift", + "macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift", + "macos/Tests/LitheTests/TCPDebugAdapterTests.swift", + "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + ".agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/debug/endpoint.rs", + "shared/fixtures/debug/adapter-endpoints-v1.json" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Debug", + "verification": "macOS:在有已安装调试提供方的源码文件上,从 Debug 更多操作打开连接调试适配器,填写已运行的 DAP 服务地址、launch/attach 与适配器参数。分别验证回环、IPv4/IPv6/DNS 外部地址、非回环默认取消的逐次确认、错误 JSON 与非法端点。确认 IDE 到 DAP 的地址不覆盖适配器到 debuggee 的地址,既有 stdio 与 tcpLanguageServer 回环限制不变。验证启动超时、单个在途写入、字节/消息预算、写入超时、EOF、解析/建连/已连接时取消和旧回调隔离;停止时断开帧最多排空 1 秒且 attach 的 terminateDebuggee 为 false,超时仍回收;停止后外部服务、共享 LSP、attach 目标与用户隧道仍存活。真实回环测试覆盖 Core initialize/attach 帧;真实 JVM/debugpy/GDB 和界面全链路仍待验收。Windows 未接入此 TCP 宿主。", + "notes": "复用 Apple Network 与 Rust DAP 引擎;用户端点独立于 attach 目标。入口复用当前文件的提供方索引,尚不发现仅附加或非文件型插件。仅直连,有可替换的连接工厂但没有代理、SSH、转发、凭据、自动重连、持久化配置或 tcpPort/tcpAnnounced 托管进程支持。运行时不写发行资源。" +} diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index beaaabda91..cf6edf017c 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -42,5 +42,6 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。" + "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。", + "notes": "普通 TCP 生命周期与用户显式 DAP 端点入口见 debug-tcp-transport;不放宽插件 tcpLanguageServer 回环约束,tcpPort/tcpAnnounced 托管模式仍拒绝。" } From 231c715453472f3bc172b2829105d270b729c3f0 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 22:33:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(debug):=20#1210=20=E4=BF=AE=E5=A4=8D=20?= =?UTF-8?q?TCP=20=E8=B0=83=E8=AF=95=E8=BF=9E=E6=8E=A5=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 被拒绝或不可达的端口立即以系统原因失败,不再停在 .waiting 等满超时 - 端点发现与建连各自独立计时:发现 120 秒(语言服务器冷启动),建连 10 秒 - 外部适配器会话初始化期限改为 10 秒建连加 10 秒握手,不再抢在建连之前结束 - 连接对话框在连接启动前保持打开,失败原因留在对话框内并保留已填内容 - connectExternalDebugAdapter 返回失败原因,不再静默丢弃请求 - 当前文件没有调试提供方时,"连接调试适配器…"菜单项不可用;主机名先去除首尾空白 - 删除宿主端口传输中无人使用的错误分支 - 补充发现/建连两段期限与真实回环拒绝连接的测试,同步 Agent Note 与功能矩阵 --- ...026-10-27-debug-tcp-transport-lifecycle.md | 8 +- macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../Models/AppModel/AppModel+Debugging.swift | 37 ++++---- .../MacHostedPortDebugAdapterTransport.swift | 11 +-- .../MacServerDebugAdapterTransport.swift | 39 ++++++++- .../Debug/MacTCPDebugAdapterTransport.swift | 28 ++++-- .../Platform/MacOS/MacServiceContainer.swift | 7 +- .../Debug/ExternalDebugAdapterView.swift | 32 +++++-- .../Lithe/Views/Debug/GenericDebugView.swift | 7 +- .../GenericDebugFeatureModel.swift | 5 ++ .../LitheTests/TCPDebugAdapterTests.swift | 86 +++++++++++++++++-- shared/i18n/locales/en-US.json | 4 +- shared/i18n/locales/zh-CN.json | 4 +- shared/i18n/messages.json | 22 +++++ .../features/debug-tcp-transport.json | 2 +- 16 files changed, 241 insertions(+), 59 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md b/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md index 8744f9ea5a..b4f566b82f 100644 --- a/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md @@ -17,12 +17,12 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器 ## 决策 - 复用 Apple Network 的 `NWConnection` 建立 TCP、解析主机并收发字节,不实现另一套 socket、DNS 或代理协议栈。Rust Core 仍负责已有的 DAP 帧、请求顺序与操作超时;平台只管理网络字节流。 -- `MacTCPDebugAdapterTransport` 管理端点解析与建连的合计期限、单个在途写入的期限、待发字节和消息数量预算。不设置会话总时长或空闲读取期限:用户停在断点上不是网络失败。写入完成只表示本机网络栈接受数据,不能据此声称适配器已处理请求。 -- 直连的默认准备和写入分别为 10 秒;语言服务器冷启动与项目导入的端点发现允许 120 秒。显式外部 DAP 另有包含建连的 10 秒初始化期限,避免误连到非 DAP 服务后永久停在初始化;这些期限都不限制已建立会话的空闲时间。待发队列含在途数据最多 8 MiB、1024 次写入。超限或超时结束该连接,不静默丢帧、不重连或重放。失联不承诺立即可知,DAP 请求仍受原有操作期限约束。 +- `MacTCPDebugAdapterTransport` 管理端点发现期限、建连期限(两段各自独立计时,发现用时不挤占建连预算)、单个在途写入的期限、待发字节和消息数量预算。不设置会话总时长或空闲读取期限:用户停在断点上不是网络失败。写入完成只表示本机网络栈接受数据,不能据此声称适配器已处理请求。 +- 直连的默认端点发现、建连和写入各为 10 秒;语言服务器冷启动与项目导入的端点发现单独允许 120 秒,端点确定后建连仍只有 10 秒,所以冷启动不会拉长对死端口的等待。Network 框架会把被拒绝或不可达的连接停在 `.waiting` 并不断重试,不会进入 `.failed`;传输把“连接被拒绝、主机或网络不可达”视为确定失败并立即结束,附上系统给出的原因,其余 `.waiting`(如暂时没有路由)仍等建连期限。显式外部 DAP 的会话初始化期限为 10 秒建连加 10 秒握手,会话计时从传输启动开始,必须比建连预算更长,避免误连到非 DAP 服务后永久停在初始化,同时不能抢在建连期限前结束;这些期限都不限制已建立会话的空闲时间。待发队列含在途数据最多 8 MiB、1024 次写入。超限或超时结束该连接,不静默丢帧、不重连或重放。失联不承诺立即可知,DAP 请求仍受原有操作期限约束。 - 用户停止会话时,先把已有 DAP disconnect 帧按原顺序交给网络栈,再关闭连接,最多等 1 秒;这不是等待远端确认,不能保证远端必然收到。传输的显式 `stop()` 仍立即中止,EOF、读写失败和超时也立即清理;不会为排空而启动尚未建立的连接。排空期间拒绝新数据、不向已结束会话投影晚到消息,超时由有界计时器持有并回收 socket。 - 停止、EOF、读写失败和超时先撤销连接代次,再取消解析任务、计时器和 socket,清空回调与缓冲。旧连接回调以及旧写入完成不能影响新连接。显式停止由会话层投影结果,传输不重复发送终止通知。 - `DebugAdapterConnectionProviding` 是未来代理的替换点:返回尚未启动、可取消的连接,握手纳入已有启动期限;停止必须释放它自己拥有的资源。本次唯一实现为直连,不提前增加代理配置、密码或进程字段。 -- `tcpLanguageServer` 仍只解析同插件语言服务器返回的回环端口。外部入口在 Debug 的更多操作中,由当前文件选择已安装的调试提供方,单独填写 DAP 地址、请求种类和适配器 JSON 参数;不猜测语言协议,不启动本地替代进程。Core 先校验地址,非回环端点每次连接前确认,明确警告裸 TCP 没有加密或认证;确认时间不计入网络建连期限。 +- `tcpLanguageServer` 仍只解析同插件语言服务器返回的回环端口。外部入口在 Debug 的更多操作中,由当前文件选择已安装的调试提供方(当前文件没有提供方时菜单项不可用),单独填写 DAP 地址、请求种类和适配器 JSON 参数;不猜测语言协议,不启动本地替代进程。连接对话框在连接启动前保持打开:被拒绝的地址、未获批准的确认、已有活动会话或连接失败都留在对话框里显示原因,已填的主机、端口和参数不丢失,用户取消会同时取消尚在进行的连接。Core 先校验地址,非回环端点每次连接前确认,明确警告裸 TCP 没有加密或认证;确认时间不计入网络建连期限。 - 外部服务只归用户所有。关闭 socket 不停止共享语言服务器、外部适配器、目标程序或用户隧道;DAP `attach` 的目标结束策略仍由现有协议层负责。配置不持久化,源码映射继续由适配器参数表达。 正确:把用户已配置的隧道最终地址填为 DAP 端点,Lithe 只连接它,停止时只关闭自己的连接。 @@ -46,7 +46,7 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器 ## 验证 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 -- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'TCPDebugAdapterTests|PluginDebugAdapterTests|DebugModuleTests'`:覆盖可控时钟、队列预算、取消与晚到回调、确认顺序、会话选路;临时回环监听器验证双向字节以及真实 Core 的 initialize/attach 帧往返,监听服务不随客户端停止。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'TCPDebugAdapterTests|PluginDebugAdapterTests|DebugModuleTests'`:覆盖可控时钟、队列预算、取消与晚到回调、确认顺序、会话选路;临时回环监听器验证双向字节以及真实 Core 的 initialize/attach 帧往返,监听服务不随客户端停止;真实连接到已释放的回环端口必须在远小于建连期限的时间内以系统原因结束,发现与建连的两段期限由可控时钟分别断言。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core debug_adapter_endpoint` 和 `cargo test --manifest-path rust/Cargo.toml -p lithe-core dap_endpoint_contract`:共享 fixture 校验及 JSON dispatcher 边界。 - `./scripts/verify-shared-contracts.sh`、`./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-agent-notes.sh`。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index f023147cd2..aa63de6db9 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -3549,6 +3549,7 @@ "Stop service" = "Stop service"; "Stop test run" = "Stop test run"; "Stop the current debug session" = "Stop the current debug session"; +"Stop the current debug session before connecting to a debug adapter." = "Stop the current debug session before connecting to a debug adapter."; "Stop the current run" = "Stop the current run"; "Stop without asking" = "Stop without asking"; "Stopped moving the remaining items to Trash" = "Stopped moving the remaining items to Trash"; @@ -3741,6 +3742,7 @@ "The debug adapter %@ needs %@, which was not found. Install it, then try again." = "The debug adapter %@ needs %@, which was not found. Install it, then try again."; "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." = "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported."; "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured." = "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured."; +"The debug adapter connection could not be started." = "The debug adapter connection could not be started."; "The debug adapter connection is stopped." = "The debug adapter connection is stopped."; "The debug adapter connection timed out." = "The debug adapter connection timed out."; "The debug adapter connection was not approved." = "The debug adapter connection was not approved."; @@ -4395,7 +4397,9 @@ "debug.tcp.launchRequest" = "Launch Request"; "debug.tcp.menu" = "Connect to Debug Adapter…"; "debug.tcp.name" = "External Debug Adapter"; +"debug.tcp.notStarted" = "The debug adapter connection could not be started."; "debug.tcp.request" = "Request"; +"debug.tcp.sessionActive" = "Stop the current debug session before connecting to a debug adapter."; "debug.tcp.startupTimedOut" = "The debug adapter connection timed out."; "debug.tcp.title" = "Connect to Debug Adapter"; "debug.tcp.writeTimedOut" = "The debug adapter did not accept outgoing data in time."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 9531158864..f87c7fc802 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3549,6 +3549,7 @@ "Stop service" = "停止服务"; "Stop test run" = "停止测试运行"; "Stop the current debug session" = "停止当前调试会话"; +"Stop the current debug session before connecting to a debug adapter." = "请先停止当前调试会话,再连接调试适配器。"; "Stop the current run" = "停止当前运行"; "Stop without asking" = "直接停止"; "Stopped moving the remaining items to Trash" = "已停止将剩余项目移到废纸篓"; @@ -3741,6 +3742,7 @@ "The debug adapter %@ needs %@, which was not found. Install it, then try again." = "调试适配器 %@ 需要 %@,但未找到。请先安装,然后重试。"; "The debug adapter %@ uses the %@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported." = "调试适配器 %@ 使用 %@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。"; "The debug adapter at %@ can access your debugging data. Plain TCP is not encrypted or authenticated. Connect only over a trusted network or a tunnel you configured." = "位于 %@ 的调试适配器可以访问你的调试数据。普通 TCP 不提供加密或身份认证。请仅通过可信网络或你自行配置的隧道连接。"; +"The debug adapter connection could not be started." = "无法启动调试适配器连接。"; "The debug adapter connection is stopped." = "调试适配器的连接已停止。"; "The debug adapter connection timed out." = "调试适配器连接超时。"; "The debug adapter connection was not approved." = "未批准连接调试适配器。"; @@ -4395,7 +4397,9 @@ "debug.tcp.launchRequest" = "启动请求"; "debug.tcp.menu" = "连接调试适配器…"; "debug.tcp.name" = "外部调试适配器"; +"debug.tcp.notStarted" = "无法启动调试适配器连接。"; "debug.tcp.request" = "请求"; +"debug.tcp.sessionActive" = "请先停止当前调试会话,再连接调试适配器。"; "debug.tcp.startupTimedOut" = "调试适配器连接超时。"; "debug.tcp.title" = "连接调试适配器"; "debug.tcp.writeTimedOut" = "调试适配器未能及时接收待发送数据。"; diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index fe36833dfa..e296dee396 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -100,21 +100,28 @@ extension AppModel { } } - func connectExternalDebugAdapter(configuration: DebugLaunchConfiguration, fileURL: URL) { - guard let identity = currentWorkspaceIdentity, let workspaceURL else { return } - Task { [weak self] in - guard let self, - let feature = await self.activateDebugModule()?.genericFeature, - self.isCurrentWorkspace(identity), !Task.isCancelled, - !feature.isSessionActive else { return } - // The selected source chooses an installed provider, not a wire - // protocol guessed from the port. A failed connection stays visible - // in Debug, rather than restoring an older session over its error. - _ = feature.start( - fileURL: fileURL, rootURL: workspaceURL, configuration: configuration - ) - self.showDebugToolWindow() - } + /// Returns the reason the connection could not be started, or nil once it has + /// started (or the request was cancelled). The caller keeps its form open on + /// a message, so the user's host, port and arguments are not lost. + func connectExternalDebugAdapter( + configuration: DebugLaunchConfiguration, fileURL: URL + ) async -> String? { + let notReady = L10n.string("The debug adapter connection could not be started.") + guard let identity = currentWorkspaceIdentity, let workspaceURL else { return notReady } + guard let feature = await activateDebugModule()?.genericFeature else { return notReady } + guard !Task.isCancelled else { return nil } + guard isCurrentWorkspace(identity) else { return notReady } + guard !feature.isSessionActive else { + return L10n.string("Stop the current debug session before connecting to a debug adapter.") + } + // The selected source chooses an installed provider, not a wire + // protocol guessed from the port. A failed connection stays visible + // in Debug, rather than restoring an older session over its error. + let started = feature.start( + fileURL: fileURL, rootURL: workspaceURL, configuration: configuration + ) + showDebugToolWindow() + return started ? nil : (feature.errorMessage ?? notReady) } private func attachJavaDebuggerAfterActivation(host: String, port: Int) async { diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift index d2b09fb37c..a952dce6f6 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift @@ -10,17 +10,11 @@ import LitheCoreContracts final class MacHostedPortDebugAdapterTransport: DebugAdapterDrainingTransport { enum TransportError: LocalizedError { case languageIntelligenceUnavailable - case connectionFailed(String) - case stopped var errorDescription: String? { switch self { case .languageIntelligenceUnavailable: return L10n.string("The language service that hosts the debug adapter is unavailable.") - case .connectionFailed(let message): - return L10n.string("Could not connect to the Debug Adapter: \(message)") - case .stopped: - return L10n.string("The debug adapter connection is stopped.") } } } @@ -54,8 +48,9 @@ final class MacHostedPortDebugAdapterTransport: DebugAdapterDrainingTransport { return DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) }, // A cold JDT/project import is endpoint discovery, not a TCP - // connect. Keep it bounded without applying the direct-host budget. - limits: .init(startupMilliseconds: 120_000), + // connect. Keep it bounded without applying the direct-host budget; + // the connect to the discovered port keeps the default short budget. + limits: .init(discoveryMilliseconds: 120_000), socketFactory: { socketFactory($0.host, UInt16($0.port)) } ) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift index daf3fb4e1b..63798688f4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift @@ -14,6 +14,20 @@ protocol DebugAdapterSocketConnection: AnyObject { func stop() } +/// NWError renders as an opaque "Network.NWError error 1"; carry the OS reason users act on. +struct DebugAdapterConnectError: LocalizedError { + let reason: String + + init(_ error: NWError) { + if case .posix(let code) = error { reason = String(cString: strerror(code.rawValue)) } + else { reason = error.localizedDescription } + } + + var errorDescription: String? { + L10n.string("Could not connect to the Debug Adapter: \(reason)") + } +} + @MainActor final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { private let connection: NWConnection @@ -23,6 +37,7 @@ final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { var onFailure: ((Error) -> Void)? var onComplete: (() -> Void)? private var stopped = false + private var reachedReady = false init(host: String, port: UInt16) { connection = NWConnection( @@ -34,9 +49,17 @@ final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { Task { @MainActor [weak self] in guard let self, !self.stopped else { return } switch state { - case .ready: self.onReady?() - case .failed(let error): self.onFailure?(error) - case .cancelled: break + case .ready: + self.reachedReady = true + self.onReady?() + case .waiting(let error) where Self.isDefinitive(error): + // Network.framework parks a refused or unreachable connect in + // .waiting and retries on path changes. A debugger gains + // nothing from silent retries, so report the cause now. Other + // causes (for example a pending Local Network prompt) keep + // waiting until the transport's connect deadline. + self.fail(error) + case .failed(let error): self.fail(error) default: break } } @@ -45,6 +68,16 @@ final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection { deinit { connection.cancel() } + private static func isDefinitive(_ error: NWError) -> Bool { + guard case .posix(let code) = error else { return false } + return [.ECONNREFUSED, .EHOSTUNREACH, .ENETUNREACH].contains(code) + } + + private func fail(_ error: NWError) { + // Only a failed connect is a "could not connect"; later I/O errors stay raw. + onFailure?(reachedReady ? error : DebugAdapterConnectError(error)) + } + func start() { guard !stopped else { return } connection.start(queue: queue) diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift index 489243d452..327eb8c68e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift @@ -18,8 +18,9 @@ struct DirectDebugAdapterConnectionProvider: DebugAdapterConnectionProviding { } /// One owned TCP stream. DAP framing, requests and their deadlines stay in Core. -/// A startup deadline covers endpoint discovery and connection establishment; -/// write deadlines cover a stalled peer, never the duration of a debug session. +/// Endpoint discovery and connection establishment each get their own deadline, +/// so a slow language-server start never lengthens a dead-port connect. Write +/// deadlines cover a stalled peer, never the duration of a debug session. // Note: .agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md @MainActor final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { @@ -39,7 +40,10 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { } struct Limits { - var startupMilliseconds = 10_000 + /// Endpoint discovery, such as a cold language-server start. + var discoveryMilliseconds = 10_000 + /// Establishing the socket once the endpoint is known. + var connectMilliseconds = 10_000 var writeMilliseconds = 10_000 var bufferedBytes = 8 * 1_024 * 1_024 var bufferedWrites = 1_024 @@ -76,7 +80,8 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { limits: Limits = Limits(), socketFactory: (@MainActor (DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection)? = nil ) { - precondition(limits.startupMilliseconds > 0 && limits.writeMilliseconds > 0) + precondition(limits.discoveryMilliseconds > 0 && limits.connectMilliseconds > 0) + precondition(limits.writeMilliseconds > 0) precondition(limits.bufferedBytes > 0 && limits.bufferedWrites > 0) precondition(limits.drainMilliseconds > 0) resolveEndpoint = endpointResolver @@ -94,10 +99,7 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { generation = UUID() let token = generation running = true - startupDeadline = scheduler.schedule(afterMilliseconds: limits.startupMilliseconds) { [weak self] in - guard let self, self.isCurrent(token), !self.ready else { return } - self.finish(error: TransportError.startupTimedOut) - } + armStartupDeadline(afterMilliseconds: limits.discoveryMilliseconds, token: token) let resolveEndpoint = resolveEndpoint startupTask = Task { @MainActor [weak self] in do { @@ -148,7 +150,17 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { private func isCurrent(_ token: UUID) -> Bool { running && generation == token } + private func armStartupDeadline(afterMilliseconds milliseconds: Int, token: UUID) { + startupDeadline?.cancel() + startupDeadline = scheduler.schedule(afterMilliseconds: milliseconds) { [weak self] in + guard let self, self.isCurrent(token), !self.ready else { return } + self.finish(error: TransportError.startupTimedOut) + } + } + private func connect(_ endpoint: DebugAdapterEndpoint, token: UUID) throws { + // Discovery is over. The connect phase gets its own, shorter budget. + armStartupDeadline(afterMilliseconds: limits.connectMilliseconds, token: token) let connection = try makeSocket(endpoint) socket = connection connection.onReady = { [weak self] in diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index b29e7d96f5..db8a3435fd 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -714,7 +714,12 @@ final class MacServiceContainer { ), core: rustCore, deadlineScheduler: MacDebugOperationDeadlineScheduler(), - initializationTimeoutMilliseconds: 10_000 + // The session clock starts before the socket exists, so it must + // outlast the transport's connect budget and still leave a full + // DAP handshake window; the transport deadline then reports a + // slow connect as itself, not as a failed `initialize`. + initializationTimeoutMilliseconds: + MacTCPDebugAdapterTransport.Limits().connectMilliseconds + 10_000 ) if let entry { debugAdapterSessionsBox.record(session, pluginID: entry.plugin.descriptor.id.rawValue) diff --git a/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift b/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift index e109baf6ed..ed354a3208 100644 --- a/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift +++ b/macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift @@ -9,7 +9,10 @@ struct ExternalDebugAdapterView: View { @State private var request = DebugRequestKind.attach @State private var arguments = "{}" @State private var errorMessage: String? - let onConnect: (DebugLaunchConfiguration) -> Void + @State private var connectTask: Task? + let onConnect: (DebugLaunchConfiguration) async -> String? + + private var trimmedHost: String { host.trimmingCharacters(in: .whitespacesAndNewlines) } private var parsedPort: Int? { guard let value = Int(port), (1...65_535).contains(value) else { return nil } @@ -41,11 +44,14 @@ struct ExternalDebugAdapterView: View { } HStack(spacing: LitheTheme.ActionButton.spacing) { Spacer() - Button("Cancel", role: .cancel) { dismiss() } + Button("Cancel", role: .cancel) { + connectTask?.cancel() + dismiss() + } .buttonStyle(LitheActionButtonStyle()).keyboardShortcut(.cancelAction) Button("Connect") { connect() } .buttonStyle(LitheActionButtonStyle(isPrimary: true)) - .disabled(parsedPort == nil || host.isEmpty) + .disabled(parsedPort == nil || trimmedHost.isEmpty || connectTask != nil) } } .padding(18) @@ -54,12 +60,22 @@ struct ExternalDebugAdapterView: View { private func connect() { guard let parsedPort else { return } + errorMessage = nil + let configuration: DebugLaunchConfiguration do { - let configuration = try ExternalDebugAdapterConfiguration.make( - host: host, port: parsedPort, request: request, argumentsJSON: arguments + configuration = try ExternalDebugAdapterConfiguration.make( + host: trimmedHost, port: parsedPort, request: request, argumentsJSON: arguments ) - onConnect(configuration) - dismiss() - } catch { errorMessage = error.localizedDescription } + } catch { + errorMessage = error.localizedDescription + return + } + // Stay open until the connection has started, so a rejected endpoint or a + // declined consent leaves the host, port and arguments editable. + connectTask = Task { + let failure = await onConnect(configuration) + connectTask = nil + if let failure { errorMessage = failure } else { dismiss() } + } } } diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index da5a2eb6ee..8cc1d57839 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -53,7 +53,7 @@ struct GenericDebugView: View { } .sheet(item: $externalAdapterSource) { source in ExternalDebugAdapterView { configuration in - model.connectExternalDebugAdapter(configuration: configuration, fileURL: source.url) + await model.connectExternalDebugAdapter(configuration: configuration, fileURL: source.url) } } .sheet(isPresented: $isJavaSteppingSettingsPresented) { @@ -409,7 +409,10 @@ struct GenericDebugView: View { externalAdapterSource = ExternalAdapterSource(url: url) } } - .disabled(model.activeDocument?.url == nil || feature.isSessionActive) + .disabled( + feature.isSessionActive + || !(model.activeDocument.map { feature.hasDebugProvider(for: $0.url) } ?? false) + ) LitheContextMenuItem.action("Clear Console") { feature.clearOutput() } .disabled(!feature.hasOutput) } label: { diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index daa0bc4198..583c1b81d9 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -2069,6 +2069,11 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + /// Whether an installed debug provider handles this file. + public func hasDebugProvider(for fileURL: URL) -> Bool { + sessions.provider(for: fileURL) != nil + } + private func sessionsProviderID(for fileURL: URL) -> String? { sessions.provider(for: fileURL)?.id } diff --git a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift index c47f9756c5..9f261d4a4e 100644 --- a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift @@ -234,6 +234,7 @@ struct TCPDebugAdapterTests { socket.onReady?() #expect(socket.writes == [Data([1])]) #expect(scheduler.deadlines[0].cancelled) + #expect(scheduler.deadlines[1].cancelled) let secondWrite = TCPTestEvent() socket.onWrite = { secondWrite.fire() } socket.completeWrite(.success(())) @@ -312,13 +313,14 @@ struct TCPDebugAdapterTests { transport.onTermination = { codes.append($0) } try transport.start(rootURL: root) #expect(await socket.started.wait(timeout: .seconds(5))) - let expiredStartup = scheduler.deadlines[0].action + // Discovery and connect each armed a startup deadline; the connect one is live. + let expiredStartup = scheduler.deadlines[1].action socket.onReady?() expiredStartup() #expect(transport.isRunning) - #expect(scheduler.deadlines.count == 1) + #expect(scheduler.deadlines.count == 2) try transport.send(Data([1])) - scheduler.deadlines[1].fire() + scheduler.deadlines[2].fire() #expect(codes == [1]) #expect(socket.stopCount == 1) } @@ -331,11 +333,34 @@ struct TCPDebugAdapterTests { defer { transport.stop() } try transport.start(rootURL: root) #expect(await socket.started.wait(timeout: .seconds(5))) - scheduler.deadlines[0].fire() + scheduler.deadlines[1].fire() #expect(!transport.isRunning) #expect(socket.stopCount == 1) } + @Test + func theConnectBudgetRestartsAfterDiscoveryInsteadOfSharingIt() async throws { + let socket = ControlledDebugSocket() + let scheduler = TCPTestScheduler() + let transport = make( + socket: socket, scheduler: scheduler, + limits: .init(discoveryMilliseconds: 120_000, connectMilliseconds: 10_000) + ) + defer { transport.stop() } + var codes: [Int] = [] + transport.onTermination = { codes.append($0) } + try transport.start(rootURL: root) + #expect(await socket.started.wait(timeout: .seconds(5))) + #expect(scheduler.deadlines.map(\.milliseconds) == [120_000, 10_000]) + // A late discovery deadline must not end the connect that replaced it. + #expect(scheduler.deadlines[0].cancelled) + scheduler.deadlines[0].fire() + #expect(transport.isRunning) + #expect(codes.isEmpty) + scheduler.deadlines[1].fire() + #expect(codes == [1]) + } + @Test(arguments: ["eof", "read", "write"]) func terminalEventsCleanUpExactlyOnceAndRevokeCallbacks(kind: String) async throws { let socket = ControlledDebugSocket() @@ -484,6 +509,27 @@ struct TCPDebugAdapterTests { #expect(!transport.isRunning) } + @Test + func aRefusedRealConnectionFailsFastWithAReadableReason() async throws { + // Network.framework parks a refused connect in .waiting and keeps retrying, + // so this only ends inside the wait if the transport fails it on purpose. + let port = try Self.unusedLoopbackPort() + let transport = MacTCPDebugAdapterTransport(endpointResolver: { _ in + DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) + }) + defer { transport.stop() } + let ended = TCPTestEvent() + var codes: [Int] = [] + var output = "" + transport.onErrorOutput = { output += String(decoding: $0, as: UTF8.self) } + transport.onTermination = { codes.append($0); ended.fire() } + try transport.start(rootURL: root) + #expect(await ended.wait(timeout: .seconds(5))) + #expect(codes == [1]) + #expect(output.contains(String(cString: strerror(ECONNREFUSED)))) + #expect(!transport.isRunning) + } + @Test func realTCPRunsCoreInitializeAndAttachWithFragmentedDAPResponses() async throws { let server = try TCPTestServer() @@ -552,6 +598,28 @@ struct TCPDebugAdapterTests { #expect(server.isReady) } + /// A loopback port that was just bound and released, so nothing listens on it. + private static func unusedLoopbackPort() throws -> UInt16 { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw TCPTestError.failed } + defer { close(descriptor) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = UInt32(INADDR_LOOPBACK).bigEndian + let size = socklen_t(MemoryLayout.size) + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, size) } + } + guard bound == 0 else { throw TCPTestError.failed } + var length = size + let named = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) } + } + guard named == 0 else { throw TCPTestError.failed } + return UInt16(bigEndian: address.sin_port) + } + private func make( socket: ControlledDebugSocket, scheduler: TCPTestScheduler? = nil, @@ -603,8 +671,8 @@ private final class TCPTestConnectionProvider: DebugAdapterConnectionProviding { @MainActor private final class TCPTestScheduler: DebugOperationDeadlineScheduling { var deadlines: [TCPTestDeadline] = [] - func schedule(afterMilliseconds _: Int, action: @escaping @MainActor () -> Void) -> any DebugOperationDeadline { - let deadline = TCPTestDeadline(action: action) + func schedule(afterMilliseconds milliseconds: Int, action: @escaping @MainActor () -> Void) -> any DebugOperationDeadline { + let deadline = TCPTestDeadline(milliseconds: milliseconds, action: action) deadlines.append(deadline) return deadline } @@ -612,9 +680,13 @@ private final class TCPTestScheduler: DebugOperationDeadlineScheduling { @MainActor private final class TCPTestDeadline: DebugOperationDeadline { + let milliseconds: Int var action: @MainActor () -> Void var cancelled = false - init(action: @escaping @MainActor () -> Void) { self.action = action } + init(milliseconds: Int, action: @escaping @MainActor () -> Void) { + self.milliseconds = milliseconds + self.action = action + } func cancel() { cancelled = true; action = {} } func fire() { if !cancelled { action() } } } diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index a3c3f91f0f..baceb22fbc 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9388,7 +9388,9 @@ "debug.tcp.argumentsHelp": "Enter the launch or attach arguments required by this adapter, including its source path mappings if needed. These are separate from the DAP server's TCP address.", "debug.tcp.invalidArguments": "Enter a JSON object of adapter arguments no larger than 256 KiB.", "debug.tcp.attachRequest": "Attach Request", - "debug.tcp.launchRequest": "Launch Request" + "debug.tcp.launchRequest": "Launch Request", + "debug.tcp.sessionActive": "Stop the current debug session before connecting to a debug adapter.", + "debug.tcp.notStarted": "The debug adapter connection could not be started." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 2ae2a78697..4507e56fb1 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9388,7 +9388,9 @@ "debug.tcp.argumentsHelp": "输入此适配器所需的启动或附加参数,必要时包含源码路径映射。这些参数独立于 DAP 服务的 TCP 地址。", "debug.tcp.invalidArguments": "请输入不超过 256 KiB 的适配器参数 JSON 对象。", "debug.tcp.attachRequest": "附加请求", - "debug.tcp.launchRequest": "启动请求" + "debug.tcp.launchRequest": "启动请求", + "debug.tcp.sessionActive": "请先停止当前调试会话,再连接调试适配器。", + "debug.tcp.notStarted": "无法启动调试适配器连接。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 0d2c32cd3a..eb8ccd0a32 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89379,6 +89379,28 @@ "key": "Launch Request", "arguments": [] } + }, + { + "id": "debug.tcp.sessionActive", + "description": "External DAP connect sheet failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "Stop the current debug session before connecting to a debug adapter.", + "arguments": [] + } + }, + { + "id": "debug.tcp.notStarted", + "description": "External DAP connect sheet failures", + "platforms": [ + "macos" + ], + "macos": { + "key": "The debug adapter connection could not be started.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/debug-tcp-transport.json b/shared/platform-feature-matrix/features/debug-tcp-transport.json index 1cf74f6cfe..1c1acfb42d 100644 --- a/shared/platform-feature-matrix/features/debug-tcp-transport.json +++ b/shared/platform-feature-matrix/features/debug-tcp-transport.json @@ -28,6 +28,6 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:在有已安装调试提供方的源码文件上,从 Debug 更多操作打开连接调试适配器,填写已运行的 DAP 服务地址、launch/attach 与适配器参数。分别验证回环、IPv4/IPv6/DNS 外部地址、非回环默认取消的逐次确认、错误 JSON 与非法端点。确认 IDE 到 DAP 的地址不覆盖适配器到 debuggee 的地址,既有 stdio 与 tcpLanguageServer 回环限制不变。验证启动超时、单个在途写入、字节/消息预算、写入超时、EOF、解析/建连/已连接时取消和旧回调隔离;停止时断开帧最多排空 1 秒且 attach 的 terminateDebuggee 为 false,超时仍回收;停止后外部服务、共享 LSP、attach 目标与用户隧道仍存活。真实回环测试覆盖 Core initialize/attach 帧;真实 JVM/debugpy/GDB 和界面全链路仍待验收。Windows 未接入此 TCP 宿主。", + "verification": "macOS:在有已安装调试提供方的源码文件上,从 Debug 更多操作打开连接调试适配器,填写已运行的 DAP 服务地址、launch/attach 与适配器参数。分别验证回环、IPv4/IPv6/DNS 外部地址、非回环默认取消的逐次确认、错误 JSON 与非法端点。确认 IDE 到 DAP 的地址不覆盖适配器到 debuggee 的地址,既有 stdio 与 tcpLanguageServer 回环限制不变。验证端点发现与建连两段独立超时(冷启动发现不缩短也不拉长建连)、被拒绝/不可达端口立即以系统原因结束、连接失败/未获批准/已有活动会话时对话框保持打开并保留已填内容、当前文件无调试提供方时菜单项不可用、单个在途写入、字节/消息预算、写入超时、EOF、解析/建连/已连接时取消和旧回调隔离;停止时断开帧最多排空 1 秒且 attach 的 terminateDebuggee 为 false,超时仍回收;停止后外部服务、共享 LSP、attach 目标与用户隧道仍存活。真实回环测试覆盖 Core initialize/attach 帧;真实 JVM/debugpy/GDB 和界面全链路仍待验收。Windows 未接入此 TCP 宿主。", "notes": "复用 Apple Network 与 Rust DAP 引擎;用户端点独立于 attach 目标。入口复用当前文件的提供方索引,尚不发现仅附加或非文件型插件。仅直连,有可替换的连接工厂但没有代理、SSH、转发、凭据、自动重连、持久化配置或 tcpPort/tcpAnnounced 托管进程支持。运行时不写发行资源。" } From 0fdadc32bd5076319f77f177eae2f958a08dad0c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 23:17:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(debug):=20#1210=20=E5=B0=86=20TCP?= =?UTF-8?q?=20=E8=B0=83=E8=AF=95=E8=BF=9E=E6=8E=A5=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F=E4=B8=8B=E6=B2=89=E5=88=B0=20Rust=20Core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连接状态转换、取消规则、代次令牌与过期结果拒绝、只结束一次、 建连/写入/排空期限、待发字节与消息预算、单个在途写入,原先都写在 macOS 的 MacTCPDebugAdapterTransport 里,Windows 接入要重写并重新 证明同一套规则。现在这些规则由 Core 的独立状态机持有,平台只做 I/O。 Core(rust/lithe-core/src/debug/transport_lifecycle.rs) - 与 DAP 协议状态机独立,按 transportId 登记。 - 新增命令 debug.transport.policy / handle / release:平台发送事件, Core 返回新代次、阶段、结果和有序效果(发现端点、设置或取消计时器、 连接、写入、投递数据、关闭、报告失败、通知终止)。 - Core 没有时钟,只决定期限长短和到期后的转换;待发数据只以字节数 越过 JSON 边界,真实字节、socket 与计时器句柄留在平台。 - 共享 fixture shared/fixtures/debug/transport-lifecycle-v1.json 覆盖 约 28 个场景,Rust 逐步回放;已登记到 v1-baseline。 macOS - 新增 RustDebugTransportCore.swift 类型化桥接。 - MacTCPDebugAdapterTransport 只拥有 NWConnection、端点发现任务、待发帧 字节和系统计时器,并执行 Core 返回的效果;core 参数贯穿托管端口、 插件和外部端点三条传输,会话初始化期限改读 Core 策略。 - 计时器触发后也释放句柄,避免排空计时器继续持有传输。 - Core 调用本身失败时本地静默拆除并向所有者报告一次。 已接受的行为差异:观察者在错误输出回调里调用 stop() 时,终止通知 现在仍会送达(旧实现把它当作生命周期被替换而吞掉);重启仍会使旧 通知作废。 文档:更新 Agent Note、功能矩阵与 rust-core-api 契约。 --- ...026-10-27-debug-tcp-transport-lifecycle.md | 19 +- .../Core/Rust/RustDebugTransportCore.swift | 236 ++ .../MacExternalDebugAdapterTransport.swift | 2 + .../MacHostedPortDebugAdapterTransport.swift | 2 + .../MacPluginDebugAdapterTransport.swift | 4 + .../Debug/MacTCPDebugAdapterTransport.swift | 327 +- .../Platform/MacOS/MacServiceContainer.swift | 15 +- .../LitheTests/PluginDebugAdapterTests.swift | 9 +- .../RealJavaDebugIntegrationTests.swift | 6 +- .../RunConfigurationIntegrationTests.swift | 2 + .../LitheTests/TCPDebugAdapterTests.swift | 21 +- rust/lithe-core/src/debug/mod.rs | 2 + .../src/debug/transport_lifecycle.rs | 902 +++++ rust/lithe-core/src/protocol/command.rs | 13 + rust/lithe-core/src/runtime/dispatcher.rs | 16 + rust/lithe-core/src/tests/protocol.rs | 35 + shared/contracts/rust-core-api.md | 86 +- .../debug/transport-lifecycle-v1.json | 3149 +++++++++++++++++ shared/fixtures/debug/v1-baseline.json | 24 + .../features/debug-tcp-transport.json | 7 +- 20 files changed, 4716 insertions(+), 161 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustDebugTransportCore.swift create mode 100644 rust/lithe-core/src/debug/transport_lifecycle.rs create mode 100644 shared/fixtures/debug/transport-lifecycle-v1.json diff --git a/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md b/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md index b4f566b82f..e2c9661762 100644 --- a/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md @@ -6,6 +6,8 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器返回的本机端口,以及用户显式填写的外部 DAP(编辑器与调试适配器之间的协议)地址,都交给同一个有界连接生命周期管理。连接地址和被调试程序的地址是两件事;前者放在宿主配置的 `adapterEndpoint`,后者仍按适配器自己的启动或附加参数提供。 +连接生命周期的规则由 Rust Core 的独立状态机持有,平台只做网络 I/O:Core 决定状态转换、取消规则、代次令牌与过期结果的拒绝、只结束一次、各段期限、待发队列预算和单个在途写入;平台适配器报告 socket、解析器和系统计时器观察到的事件,再按 Core 返回的步骤执行。这样 macOS 继续使用 `NWConnection`,Windows 可以用自己的网络层,两端共用同一套规则和生命周期测试。 + 本次只实现直连,不创建代理、SSH、隧道或端口转发。未来网络通路通过可替换的连接工厂接入,不在 DAP 协议状态机里拼接网络命令。完整网络方案仍见[独立提案](../../proposed/architecture/2026-10-27-debug-network-paths-and-tcp-lifecycle.md)。 ## 问题 @@ -17,10 +19,13 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器 ## 决策 - 复用 Apple Network 的 `NWConnection` 建立 TCP、解析主机并收发字节,不实现另一套 socket、DNS 或代理协议栈。Rust Core 仍负责已有的 DAP 帧、请求顺序与操作超时;平台只管理网络字节流。 -- `MacTCPDebugAdapterTransport` 管理端点发现期限、建连期限(两段各自独立计时,发现用时不挤占建连预算)、单个在途写入的期限、待发字节和消息数量预算。不设置会话总时长或空闲读取期限:用户停在断点上不是网络失败。写入完成只表示本机网络栈接受数据,不能据此声称适配器已处理请求。 +- 生命周期状态机在 `rust/lithe-core/src/debug/transport_lifecycle.rs`,与 DAP 协议状态机相互独立,按 `transportId` 登记。平台通过 `debug.transport.handle` 发送事件(`start`、`send`、`stop`、`finishSendingAndStop`,以及带代次的 `endpointResolved`、`connectionReady`、`dataReceived`、`peerClosed`、`failed`、`writeCompleted`、`timerFired`),Core 更新状态并返回新代次、阶段、结果和有序的效果(发现端点、设置或取消计时器、连接、写入、投递数据、关闭、报告失败、通知终止)。`debug.transport.policy` 返回默认期限与预算以及会话初始化窗口,`debug.transport.release` 释放登记。 +- Core 没有时钟:它只决定期限的长度和到期后的转换,宿主负责等待并回报“计时器已触发”。待发数据只以字节数越过 JSON 边界,真实字节、socket、计时器句柄留在平台;平台在 `send` 里先按顺序登记帧,`write` 效果按先进先出取出对应的一帧。代次令牌、计时器编号和写入编号在 Core 中唯一且不复用,晚到的回调因代次或编号不符而被忽略。 +- `MacTCPDebugAdapterTransport` 只拥有 `NWConnection`(经 `DebugAdapterSocketConnection`)、端点发现任务、待发帧的字节、系统计时器句柄,并执行 Core 的效果。端点发现、建连(两段各自独立计时,发现用时不挤占建连预算)、单个在途写入的期限、待发字节和消息数量预算都由 Core 判定。不设置会话总时长或空闲读取期限:用户停在断点上不是网络失败。写入完成只表示本机网络栈接受数据,不能据此声称适配器已处理请求。 - 直连的默认端点发现、建连和写入各为 10 秒;语言服务器冷启动与项目导入的端点发现单独允许 120 秒,端点确定后建连仍只有 10 秒,所以冷启动不会拉长对死端口的等待。Network 框架会把被拒绝或不可达的连接停在 `.waiting` 并不断重试,不会进入 `.failed`;传输把“连接被拒绝、主机或网络不可达”视为确定失败并立即结束,附上系统给出的原因,其余 `.waiting`(如暂时没有路由)仍等建连期限。显式外部 DAP 的会话初始化期限为 10 秒建连加 10 秒握手,会话计时从传输启动开始,必须比建连预算更长,避免误连到非 DAP 服务后永久停在初始化,同时不能抢在建连期限前结束;这些期限都不限制已建立会话的空闲时间。待发队列含在途数据最多 8 MiB、1024 次写入。超限或超时结束该连接,不静默丢帧、不重连或重放。失联不承诺立即可知,DAP 请求仍受原有操作期限约束。 - 用户停止会话时,先把已有 DAP disconnect 帧按原顺序交给网络栈,再关闭连接,最多等 1 秒;这不是等待远端确认,不能保证远端必然收到。传输的显式 `stop()` 仍立即中止,EOF、读写失败和超时也立即清理;不会为排空而启动尚未建立的连接。排空期间拒绝新数据、不向已结束会话投影晚到消息,超时由有界计时器持有并回收 socket。 -- 停止、EOF、读写失败和超时先撤销连接代次,再取消解析任务、计时器和 socket,清空回调与缓冲。旧连接回调以及旧写入完成不能影响新连接。显式停止由会话层投影结果,传输不重复发送终止通知。 +- 停止、EOF、读写失败和超时先由 Core 递增代次,再由 `close` 效果取消解析任务、全部剩余计时器和 socket,清空回调与缓冲。旧连接回调以及旧写入完成不能影响新连接。显式停止由会话层投影结果,传输不重复发送终止通知。失败时 Core 先给出 `close`、`reportFailure`、`notifyTermination`;宿主执行每个效果前核对代次,观察者在错误输出回调里重新 `start` 会使剩余的旧终止通知作废。已处于停止状态的传输再收到 `stop` 则被 Core 忽略,所以观察者在错误输出回调里调用 `stop()` 时终止通知仍然送达,且只送达一次;旧实现把这种情况也当作“生命周期已被替换”而吞掉通知,这个差异是有意接受的。 +- 宿主若发现 Core 调用本身失败(桥接故障而不是生命周期结果),在本地静默拆除 socket 与计时器、释放登记,并向所有者报告一次,不让资源在故障后残留。 - `DebugAdapterConnectionProviding` 是未来代理的替换点:返回尚未启动、可取消的连接,握手纳入已有启动期限;停止必须释放它自己拥有的资源。本次唯一实现为直连,不提前增加代理配置、密码或进程字段。 - `tcpLanguageServer` 仍只解析同插件语言服务器返回的回环端口。外部入口在 Debug 的更多操作中,由当前文件选择已安装的调试提供方(当前文件没有提供方时菜单项不可用),单独填写 DAP 地址、请求种类和适配器 JSON 参数;不猜测语言协议,不启动本地替代进程。连接对话框在连接启动前保持打开:被拒绝的地址、未获批准的确认、已有活动会话或连接失败都留在对话框里显示原因,已填的主机、端口和参数不丢失,用户取消会同时取消尚在进行的连接。Core 先校验地址,非回环端点每次连接前确认,明确警告裸 TCP 没有加密或认证;确认时间不计入网络建连期限。 - 外部服务只归用户所有。关闭 socket 不停止共享语言服务器、外部适配器、目标程序或用户隧道;DAP `attach` 的目标结束策略仍由现有协议层负责。配置不持久化,源码映射继续由适配器参数表达。 @@ -33,13 +38,16 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器 - **在旧端口传输上直接放开主机限制。** 缺少独立来源与确认,而且不能保护旧回环信任边界;改为保留端点来源并共用底层生命周期。 - **为外部 DAP 新写一个 TCP 传输。** 取消、缓冲和晚到回调会出现两套规则;改为端点解析与连接建立分开复用。 +- **把生命周期规则继续留在 Swift,只加测试。** 初版就是这样:期限、预算、代次和只结束一次都写在 macOS 传输里,Windows 要重新实现并重新证明同一套规则。改为 Core 持有规则、平台持有 I/O。 +- **让 Core 持有 socket、DNS 或时钟。** 这会把平台网络栈与系统计时器搬进跨平台内核,并强迫 Core 依赖运行时和真实时间,测试不再确定。改为 Core 只返回下一步,宿主执行并回报。 +- **让待发字节穿过 JSON 边界。** 帧最大可达数 MiB,复制和编码代价无谓,且 Core 只需要字节数做预算。改为只传字节数,帧留在宿主。 - **本轮同时实现代理或统一成 SSH。** 认证、身份校验、共享隧道和辅助进程归属还需要独立设计;没有 SSH 也可以直连或经过用户的 VPN,不把 SSH 当作必要前提。 ## 后果 普通 TCP 连接可独立测试,stdio 原有路径不变,用户提供的外部地址与语言服务器端口遵守各自的信任边界。代价是外部连接需要用户了解其适配器参数,没有保存配置、通路自动建立或断线恢复;插件 `tcpPort`、`tcpAnnounced` 的托管进程模式也仍未实现。 -此处代码实现不等于所有语言适配器都已验收。真实 JVM、debugpy、GDB 的兼容性仍需对应插件和端到端场景证明;Windows 只共享地址校验,尚无本次宿主 TCP 实现。 +此处代码实现不等于所有语言适配器都已验收。真实 JVM、debugpy、GDB 的兼容性仍需对应插件和端到端场景证明;Windows 共享地址校验与这套生命周期状态机,但尚无宿主 TCP 实现,接入时只需实现 socket、解析和计时器这些 I/O 并执行 Core 返回的效果,状态机本身不需要再写一遍。 外部入口复用当前文件的调试提供方索引,该索引仍只选择支持文件启动的插件(Java 使用宿主提供方)。因此本次不支持单独发现仅附加进程、可执行文件或其他非文件型插件;不要把 TCP 字节流可用等同于这些插件的产品入口已经接通。 @@ -48,6 +56,7 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器 - `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'TCPDebugAdapterTests|PluginDebugAdapterTests|DebugModuleTests'`:覆盖可控时钟、队列预算、取消与晚到回调、确认顺序、会话选路;临时回环监听器验证双向字节以及真实 Core 的 initialize/attach 帧往返,监听服务不随客户端停止;真实连接到已释放的回环端口必须在远小于建连期限的时间内以系统原因结束,发现与建连的两段期限由可控时钟分别断言。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core debug_adapter_endpoint` 和 `cargo test --manifest-path rust/Cargo.toml -p lithe-core dap_endpoint_contract`:共享 fixture 校验及 JSON dispatcher 边界。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core transport_lifecycle`:状态机单元测试,并逐步回放 `shared/fixtures/debug/transport-lifecycle-v1.json` 中的场景(启动与取消、过期代次、只结束一次、各段期限、预算超限、单个在途写入、排空)。新平台实现同一份 fixture 即可证明规则一致;`protocol` 测试覆盖 `debug.transport.*` 命令的 JSON 边界。 - `./scripts/verify-shared-contracts.sh`、`./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-agent-notes.sh`。 ## 适用范围 @@ -55,5 +64,9 @@ macOS 的普通 TCP 调试连接现在共用一个传输实现:语言服务器 - `macos/Sources/Lithe/Platform/MacOS/Debug/` - `macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift` - `macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift` +- `macos/Sources/Lithe/Core/Rust/RustDebugTransportCore.swift` - `rust/lithe-core/src/debug/endpoint.rs` +- `rust/lithe-core/src/debug/transport_lifecycle.rs` - `shared/fixtures/debug/adapter-endpoints-v1.json` +- `shared/fixtures/debug/transport-lifecycle-v1.json` +- `shared/contracts/rust-core-api.md` diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugTransportCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugTransportCore.swift new file mode 100644 index 0000000000..79d827e4c7 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustDebugTransportCore.swift @@ -0,0 +1,236 @@ +import Foundation + +/// Rust Core's lifecycle rules for one IDE-to-adapter byte stream: state +/// transitions, generation tokens, deadlines, queue budgets and the single +/// in-flight write. The platform reports what happened and runs the effects Core +/// returns; Core never opens a socket, reads a clock or sees the bytes. +// Note: .agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md +protocol DebugTransportLifecycleCore: Sendable { + func debugTransportPolicy() throws -> DebugTransportPolicy + func handleDebugTransportEvent(transportID: String, event: DebugTransportEvent) throws -> DebugTransportStep + func releaseDebugTransport(transportID: String) throws +} + +/// Per-transport overrides; a `nil` field keeps Core's default. +struct DebugTransportLimitOverrides: Encodable, Equatable, Sendable { + var discoveryMilliseconds: Int? + var connectMilliseconds: Int? + var writeMilliseconds: Int? + var bufferedBytes: Int? + var bufferedWrites: Int? + var drainMilliseconds: Int? +} + +struct DebugTransportLimits: Decodable, Equatable, Sendable { + let discoveryMilliseconds: Int + let connectMilliseconds: Int + let writeMilliseconds: Int + let bufferedBytes: Int + let bufferedWrites: Int + let drainMilliseconds: Int +} + +struct DebugTransportPolicy: Decodable, Equatable, Sendable { + let limits: DebugTransportLimits + /// The window a DAP `initialize` round trip gets, so a session never gives up before its transport does. + let initializationMilliseconds: Int +} + +enum DebugTransportPhase: String, Decodable, Equatable, Sendable { + case stopped, discovering, connecting, ready, draining +} + +enum DebugTransportOutcome: String, Decodable, Equatable, Sendable { + case applied, ignored, rejectedNotRunning, rejectedBufferLimit +} + +enum DebugTransportTimerPurpose: String, Decodable, Equatable, Sendable { + case discovery, connect, write, drain +} + +enum DebugTransportFailureReason: String, Decodable, Equatable, Sendable { + case startupTimedOut, writeTimedOut, bufferLimit, invalidEndpoint, host +} + +/// What the platform reports or asks for. Callback events carry the `generation` +/// the platform captured when it started the work they answer. +enum DebugTransportEvent: Encodable, Equatable, Sendable { + case start(DebugTransportLimitOverrides) + case send(bytes: Int) + case stop + case finishSendingAndStop + case endpointResolved(generation: UInt64, host: String, port: Int) + case connectionReady(generation: UInt64) + case dataReceived(generation: UInt64) + case peerClosed(generation: UInt64) + case failed(generation: UInt64, message: String) + case writeCompleted(generation: UInt64, writeID: UInt64) + case timerFired(generation: UInt64, timerID: UInt64) + + private enum CodingKeys: String, CodingKey { + case type, limits, bytes, generation, host, port, message + case writeID = "writeId" + case timerID = "timerId" + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .start(let limits): + try container.encode("start", forKey: .type) + try container.encode(limits, forKey: .limits) + case .send(let bytes): + try container.encode("send", forKey: .type) + try container.encode(bytes, forKey: .bytes) + case .stop: + try container.encode("stop", forKey: .type) + case .finishSendingAndStop: + try container.encode("finishSendingAndStop", forKey: .type) + case .endpointResolved(let generation, let host, let port): + try container.encode("endpointResolved", forKey: .type) + try container.encode(generation, forKey: .generation) + try container.encode(host, forKey: .host) + try container.encode(port, forKey: .port) + case .connectionReady(let generation): + try container.encode("connectionReady", forKey: .type) + try container.encode(generation, forKey: .generation) + case .dataReceived(let generation): + try container.encode("dataReceived", forKey: .type) + try container.encode(generation, forKey: .generation) + case .peerClosed(let generation): + try container.encode("peerClosed", forKey: .type) + try container.encode(generation, forKey: .generation) + case .failed(let generation, let message): + try container.encode("failed", forKey: .type) + try container.encode(generation, forKey: .generation) + try container.encode(message, forKey: .message) + case .writeCompleted(let generation, let writeID): + try container.encode("writeCompleted", forKey: .type) + try container.encode(generation, forKey: .generation) + try container.encode(writeID, forKey: .writeID) + case .timerFired(let generation, let timerID): + try container.encode("timerFired", forKey: .type) + try container.encode(generation, forKey: .generation) + try container.encode(timerID, forKey: .timerID) + } + } +} + +/// Work the platform performs, in order, after a step. +enum DebugTransportEffect: Decodable, Equatable, Sendable { + case discoverEndpoint + case armTimer(timerID: UInt64, purpose: DebugTransportTimerPurpose, afterMilliseconds: Int) + case cancelTimer(timerID: UInt64) + case connect(host: String, port: UInt16) + /// Send the oldest queued frame, which is `bytes` long. + case write(writeID: UInt64, bytes: Int) + case deliverData + case close + case reportFailure(reason: DebugTransportFailureReason, message: String?) + case notifyTermination(exitCode: Int) + + private enum CodingKeys: String, CodingKey { + case kind, purpose, host, port, bytes, reason, message, exitCode, afterMilliseconds + case timerID = "timerId" + case writeID = "writeId" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "discoverEndpoint": + self = .discoverEndpoint + case "armTimer": + self = .armTimer( + timerID: try container.decode(UInt64.self, forKey: .timerID), + purpose: try container.decode(DebugTransportTimerPurpose.self, forKey: .purpose), + afterMilliseconds: try container.decode(Int.self, forKey: .afterMilliseconds) + ) + case "cancelTimer": + self = .cancelTimer(timerID: try container.decode(UInt64.self, forKey: .timerID)) + case "connect": + self = .connect( + host: try container.decode(String.self, forKey: .host), + port: try container.decode(UInt16.self, forKey: .port) + ) + case "write": + self = .write( + writeID: try container.decode(UInt64.self, forKey: .writeID), + bytes: try container.decode(Int.self, forKey: .bytes) + ) + case "deliverData": + self = .deliverData + case "close": + self = .close + case "reportFailure": + self = .reportFailure( + reason: try container.decode(DebugTransportFailureReason.self, forKey: .reason), + message: try container.decodeIfPresent(String.self, forKey: .message) + ) + case "notifyTermination": + self = .notifyTermination(exitCode: try container.decode(Int.self, forKey: .exitCode)) + default: + throw DecodingError.dataCorruptedError( + forKey: .kind, in: container, + debugDescription: "Unknown debug transport effect \(kind)" + ) + } + } +} + +/// Core's answer to one event. +struct DebugTransportStep: Decodable, Equatable, Sendable { + /// Generation after the step; callbacks of the work it starts must carry it. + let generation: UInt64 + let phase: DebugTransportPhase + let outcome: DebugTransportOutcome + let effects: [DebugTransportEffect] +} + +private struct DebugTransportPolicyPayload: Encodable {} + +private struct DebugTransportEventPayload: Encodable { + let transportID: String + let event: DebugTransportEvent + + private enum CodingKeys: String, CodingKey { + case transportID = "transportId" + case event + } +} + +private struct DebugTransportReleasePayload: Encodable { + let transportID: String + + private enum CodingKeys: String, CodingKey { + case transportID = "transportId" + } +} + +private struct DebugTransportReleaseResult: Decodable { + let released: Bool +} + +extension RustCoreBridge: DebugTransportLifecycleCore { + func debugTransportPolicy() throws -> DebugTransportPolicy { + try executeResult(command: "debug.transport.policy", payload: DebugTransportPolicyPayload()).get() + } + + func handleDebugTransportEvent( + transportID: String, + event: DebugTransportEvent + ) throws -> DebugTransportStep { + try executeResult( + command: "debug.transport.handle", + payload: DebugTransportEventPayload(transportID: transportID, event: event) + ).get() + } + + func releaseDebugTransport(transportID: String) throws { + let _: DebugTransportReleaseResult = try executeResult( + command: "debug.transport.release", + payload: DebugTransportReleasePayload(transportID: transportID) + ).get() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift index 130b34ba82..c08b3998b0 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift @@ -18,6 +18,7 @@ enum MacExternalDebugAdapterTransport { endpoint: DebugAdapterEndpoint, validate: @MainActor (DebugAdapterEndpoint) throws -> ValidatedEndpoint, confirm: @MainActor (DebugAdapterEndpoint) -> Bool = confirmConnection, + core: any DebugTransportLifecycleCore, connectionProvider: (any DebugAdapterConnectionProviding)? = nil ) throws -> MacTCPDebugAdapterTransport { let checked = try validate(endpoint) @@ -29,6 +30,7 @@ enum MacExternalDebugAdapterTransport { } return MacTCPDebugAdapterTransport( endpointResolver: { _ in destination }, + core: core, connectionProvider: connectionProvider ) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift index a952dce6f6..0845bf65a8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift @@ -38,6 +38,7 @@ final class MacHostedPortDebugAdapterTransport: DebugAdapterDrainingTransport { init( portResolver: @escaping PortResolver, + core: any DebugTransportLifecycleCore, socketFactory: @escaping @MainActor (String, UInt16) -> any DebugAdapterSocketConnection = { NetworkDebugAdapterSocketConnection(host: $0, port: $1) } @@ -47,6 +48,7 @@ final class MacHostedPortDebugAdapterTransport: DebugAdapterDrainingTransport { let port = try await portResolver(root) return DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) }, + core: core, // A cold JDT/project import is endpoint discovery, not a TCP // connect. Keep it bounded without applying the direct-host budget; // the connect to the discovered port keeps the default short budget. diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift index 56e86e477f..901fe92b76 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacPluginDebugAdapterTransport.swift @@ -31,6 +31,7 @@ final class MacPluginDebugAdapterTransport: DebugAdapterDrainingTransport { private let resolve: @MainActor (URL) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch private let adapterID: String private let makeProcess: @MainActor () -> any RawProcessSession + private let core: any DebugTransportLifecycleCore /// Finds a system command by tool discovery. Never a project-local copy. private let findCommand: @MainActor ([String]) -> URL? private let startHostedPort: HostedPortStarter @@ -45,6 +46,7 @@ final class MacPluginDebugAdapterTransport: DebugAdapterDrainingTransport { adapterID: String, resolve: @escaping @MainActor (URL) throws -> MacPluginLanguageServerLaunchResolver.DebugAdapterLaunch, makeProcess: @escaping @MainActor () -> any RawProcessSession, + core: any DebugTransportLifecycleCore, findCommand: @escaping @MainActor ([String]) -> URL? = { _ in nil }, startHostedPort: @escaping HostedPortStarter = { _, _, _ in throw MacHostedPortDebugAdapterTransport.TransportError.languageIntelligenceUnavailable @@ -57,6 +59,7 @@ final class MacPluginDebugAdapterTransport: DebugAdapterDrainingTransport { self.findCommand = findCommand self.resolve = resolve self.makeProcess = makeProcess + self.core = core self.startHostedPort = startHostedPort self.socketFactory = socketFactory } @@ -72,6 +75,7 @@ final class MacPluginDebugAdapterTransport: DebugAdapterDrainingTransport { let startHostedPort = startHostedPort transport = MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await startHostedPort(languageID, command, rootURL) }, + core: core, socketFactory: socketFactory ) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift index 327eb8c68e..588d5db05c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift @@ -17,10 +17,11 @@ struct DirectDebugAdapterConnectionProvider: DebugAdapterConnectionProviding { } } -/// One owned TCP stream. DAP framing, requests and their deadlines stay in Core. -/// Endpoint discovery and connection establishment each get their own deadline, -/// so a slow language-server start never lengthens a dead-port connect. Write -/// deadlines cover a stalled peer, never the duration of a debug session. +/// One owned TCP stream. The lifecycle rules live in Rust Core: this adapter +/// reports what the socket, the resolver and the system timers observed, then runs +/// the effects Core returns. It owns the socket, the pending frames' bytes, the +/// timer handles and nothing that decides when to give up. DAP framing, requests +/// and their deadlines stay in Core as well. // Note: .agents/notes/implemented/architecture/2026-10-27-debug-tcp-transport-lifecycle.md @MainActor final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { @@ -39,35 +40,34 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { } } - struct Limits { - /// Endpoint discovery, such as a cold language-server start. - var discoveryMilliseconds = 10_000 - /// Establishing the socket once the endpoint is known. - var connectMilliseconds = 10_000 - var writeMilliseconds = 10_000 - var bufferedBytes = 8 * 1_024 * 1_024 - var bufferedWrites = 1_024 - var drainMilliseconds = 1_000 - } + /// Per-transport overrides of Core's deadline and queue defaults. + typealias Limits = DebugTransportLimitOverrides typealias EndpointResolver = @MainActor (URL) async throws -> DebugAdapterEndpoint private let resolveEndpoint: EndpointResolver private let makeSocket: @MainActor (DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection private let scheduler: any DebugOperationDeadlineScheduling + private let core: any DebugTransportLifecycleCore private let limits: Limits + private let transportID = UUID().uuidString + private var rootURL: URL? private var startupTask: Task? - private var startupDeadline: (any DebugOperationDeadline)? - private var writeDeadline: (any DebugOperationDeadline)? - private var drainDeadline: (any DebugOperationDeadline)? private var socket: (any DebugAdapterSocketConnection)? + /// Frames Core has budgeted but not yet told us to write. Core never sees the bytes. private var writes: [Data] = [] - private var bufferedBytes = 0 - private var activeWrite: UUID? - private var generation = UUID() - private var ready = false - private var running = false - private var draining = false - var isRunning: Bool { running && !draining } + /// System timers Core armed, keyed by Core's timer id. + private var timers: [UInt64: any DebugOperationDeadline] = [:] + /// Mirrors of Core's last answer; Core stays the authority. + private var generation: UInt64 = 0 + private var phase = DebugTransportPhase.stopped + /// Nonzero while effects run, so an inline callback does not recurse into Core. + private var effectDepth = 0 + var isRunning: Bool { + switch phase { + case .discovering, .connecting, .ready: return true + case .stopped, .draining: return false + } + } var onData: ((Data) -> Void)? var onErrorOutput: ((Data) -> Void)? @@ -75,167 +75,208 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { init( endpointResolver: @escaping EndpointResolver, + core: any DebugTransportLifecycleCore, connectionProvider: (any DebugAdapterConnectionProviding)? = nil, deadlineScheduler: (any DebugOperationDeadlineScheduling)? = nil, limits: Limits = Limits(), socketFactory: (@MainActor (DebugAdapterEndpoint) throws -> any DebugAdapterSocketConnection)? = nil ) { - precondition(limits.discoveryMilliseconds > 0 && limits.connectMilliseconds > 0) - precondition(limits.writeMilliseconds > 0) - precondition(limits.bufferedBytes > 0 && limits.bufferedWrites > 0) - precondition(limits.drainMilliseconds > 0) resolveEndpoint = endpointResolver let provider = connectionProvider ?? DirectDebugAdapterConnectionProvider() makeSocket = socketFactory ?? { try provider.makeConnection(to: $0) } scheduler = deadlineScheduler ?? MacDebugOperationDeadlineScheduler() + self.core = core self.limits = limits } - isolated deinit { cleanUp() } + isolated deinit { + // Core's state for this transport goes with it; no event is needed. + closeConnection() + try? core.releaseDebugTransport(transportID: transportID) + } func start(rootURL: URL) throws { - if draining { cleanUp() } - guard !isRunning else { return } - generation = UUID() - let token = generation - running = true - armStartupDeadline(afterMilliseconds: limits.discoveryMilliseconds, token: token) - let resolveEndpoint = resolveEndpoint - startupTask = Task { @MainActor [weak self] in - do { - let endpoint = try await resolveEndpoint(rootURL.standardizedFileURL) - try Task.checkCancellation() - guard let self, self.isCurrent(token) else { return } - // User endpoints are validated by Core before reaching this layer. - // Keep the native numeric conversion safe for every provider. - guard !endpoint.host.isEmpty, (1...65_535).contains(endpoint.port) else { - throw TransportError.invalidEndpoint - } - try self.connect(endpoint, token: token) - } catch { - guard let self, self.isCurrent(token) else { return } - self.finish(error: error) - } - } + let step = try core.handleDebugTransportEvent(transportID: transportID, event: .start(limits)) + if step.outcome == .applied { self.rootURL = rootURL.standardizedFileURL } + apply(step) } func send(_ data: Data) throws { - guard isRunning else { throw TransportError.stopped } - guard !data.isEmpty else { return } - guard writes.count < limits.bufferedWrites, - data.count <= limits.bufferedBytes - bufferedBytes else { - finish(error: TransportError.bufferLimit) + // The effect that writes this frame can arrive in the same step. + if !data.isEmpty { writes.append(data) } + let step: DebugTransportStep + do { + step = try core.handleDebugTransportEvent(transportID: transportID, event: .send(bytes: data.count)) + } catch { + if !data.isEmpty { writes.removeLast() } + throw error + } + switch step.outcome { + case .rejectedNotRunning: + if !data.isEmpty { writes.removeLast() } + throw TransportError.stopped + case .rejectedBufferLimit: + // The step fails the stream, reports it and clears the queue. + apply(step) throw TransportError.bufferLimit + case .applied, .ignored: + apply(step) } - writes.append(data) - bufferedBytes += data.count - sendNext(token: generation) } - func stop() { cleanUp() } + func stop() { dispatch(.stop) } + + func finishSendingAndStop() { dispatch(.finishSendingAndStop) } - func finishSendingAndStop() { - guard running, !draining else { return } - guard ready, !writes.isEmpty else { cleanUp(); return } - draining = true - let token = generation - // The session manager can release its session immediately. The bounded - // drain owns itself until the queued disconnect is accepted or expires. - drainDeadline = scheduler.schedule(afterMilliseconds: limits.drainMilliseconds) { [self] in - guard isCurrent(token), draining else { return } - cleanUp() + /// Reports an event to Core and runs the answer. A Core failure is a bridge + /// fault, not a lifecycle outcome, so it fails the stream locally. + private func dispatch(_ event: DebugTransportEvent, inbound: Data? = nil) { + do { + apply(try core.handleDebugTransportEvent(transportID: transportID, event: event), inbound: inbound) + } catch { + abort(after: error) } - sendNext(token: generation) } - private func isCurrent(_ token: UUID) -> Bool { running && generation == token } + private func apply(_ step: DebugTransportStep, inbound: Data? = nil) { + generation = step.generation + phase = step.phase + effectDepth += 1 + defer { effectDepth -= 1 } + for effect in step.effects { + // A callback or an observer may have stopped or restarted the stream + // while an earlier effect ran; the rest of this step is stale. + guard generation == step.generation else { return } + run(effect, generation: step.generation, inbound: inbound) + } + } - private func armStartupDeadline(afterMilliseconds milliseconds: Int, token: UUID) { - startupDeadline?.cancel() - startupDeadline = scheduler.schedule(afterMilliseconds: milliseconds) { [weak self] in - guard let self, self.isCurrent(token), !self.ready else { return } - self.finish(error: TransportError.startupTimedOut) + private func run(_ effect: DebugTransportEffect, generation: UInt64, inbound: Data?) { + switch effect { + case .discoverEndpoint: + discoverEndpoint(generation: generation) + case .armTimer(let timerID, let purpose, let afterMilliseconds): + armTimer(timerID, purpose: purpose, afterMilliseconds: afterMilliseconds, generation: generation) + case .cancelTimer(let timerID): + timers.removeValue(forKey: timerID)?.cancel() + case .connect(let host, let port): + connect(DebugAdapterEndpoint(host: host, port: Int(port)), generation: generation) + case .write(let writeID, let bytes): + write(writeID, bytes: bytes, generation: generation) + case .deliverData: + if let inbound { onData?(inbound) } + case .close: + closeConnection() + case .reportFailure(let reason, let message): + onErrorOutput?(Data((failureText(reason, message: message) + "\n").utf8)) + case .notifyTermination(let exitCode): + onTermination?(exitCode) } } - private func connect(_ endpoint: DebugAdapterEndpoint, token: UUID) throws { - // Discovery is over. The connect phase gets its own, shorter budget. - armStartupDeadline(afterMilliseconds: limits.connectMilliseconds, token: token) - let connection = try makeSocket(endpoint) + private func failureText(_ reason: DebugTransportFailureReason, message: String?) -> String { + switch reason { + case .startupTimedOut: return TransportError.startupTimedOut.localizedDescription + case .writeTimedOut: return TransportError.writeTimedOut.localizedDescription + case .bufferLimit: return TransportError.bufferLimit.localizedDescription + case .invalidEndpoint: return TransportError.invalidEndpoint.localizedDescription + case .host: return message ?? "" + } + } + + private func discoverEndpoint(generation: UInt64) { + guard let rootURL else { return } + let resolveEndpoint = resolveEndpoint + startupTask = Task { @MainActor [weak self] in + do { + let endpoint = try await resolveEndpoint(rootURL) + try Task.checkCancellation() + self?.dispatch(.endpointResolved(generation: generation, host: endpoint.host, port: endpoint.port)) + } catch { + guard !Task.isCancelled else { return } + self?.dispatch(.failed(generation: generation, message: error.localizedDescription)) + } + } + } + + private func armTimer( + _ timerID: UInt64, + purpose: DebugTransportTimerPurpose, + afterMilliseconds: Int, + generation: UInt64 + ) { + // The bounded drain owns the transport: the session manager can release it + // right after asking to finish, and the queued disconnect still gets its window. + let fire: @MainActor () -> Void = purpose == .drain + ? { [self] in timerFired(timerID, generation: generation) } + : { [weak self] in self?.timerFired(timerID, generation: generation) } + timers[timerID] = scheduler.schedule(afterMilliseconds: afterMilliseconds, action: fire) + } + + private func timerFired(_ timerID: UInt64, generation: UInt64) { + // Core takes the fired timer's slot itself; only the handle is ours to drop. + // Cancelling a timer that already fired is a no-op that releases whatever + // the handle still retains, such as the drain timer's strong owner. + let handle = timers.removeValue(forKey: timerID) + dispatch(.timerFired(generation: generation, timerID: timerID)) + handle?.cancel() + } + + private func connect(_ endpoint: DebugAdapterEndpoint, generation: UInt64) { + startupTask = nil + let connection: any DebugAdapterSocketConnection + do { + connection = try makeSocket(endpoint) + } catch { + dispatch(.failed(generation: generation, message: error.localizedDescription)) + return + } socket = connection connection.onReady = { [weak self] in - guard let self, self.isCurrent(token), !self.ready else { return } - self.ready = true - self.startupDeadline?.cancel() - self.startupDeadline = nil - self.startupTask = nil - self.sendNext(token: token) + self?.dispatch(.connectionReady(generation: generation)) } connection.onData = { [weak self] data in - guard let self, self.isCurrent(token), !self.draining else { return } - self.onData?(data) + self?.dispatch(.dataReceived(generation: generation), inbound: data) } connection.onFailure = { [weak self] error in - guard let self, self.isCurrent(token) else { return } - self.finish(error: error) + self?.dispatch(.failed(generation: generation, message: error.localizedDescription)) } connection.onComplete = { [weak self] in - guard let self, self.isCurrent(token) else { return } - self.finish(error: nil) + self?.dispatch(.peerClosed(generation: generation)) } connection.start() } - private func sendNext(token: UUID) { - guard isCurrent(token), ready, activeWrite == nil, - let data = writes.first, let socket else { return } - let writeID = UUID() - activeWrite = writeID - writeDeadline = scheduler.schedule(afterMilliseconds: limits.writeMilliseconds) { [weak self] in - guard let self, self.isCurrent(token), self.activeWrite == writeID else { return } - self.finish(error: TransportError.writeTimedOut) + private func write(_ writeID: UInt64, bytes: Int, generation: UInt64) { + guard let socket, !writes.isEmpty else { + assertionFailure("Core asked for a write the adapter has no frame or socket for") + return } + let data = writes.removeFirst() + assert(data.count == bytes, "Core's frame size disagrees with the queued frame") socket.send(data) { [weak self] result in - guard let self, self.isCurrent(token), self.activeWrite == writeID else { return } - self.writeDeadline?.cancel() - self.writeDeadline = nil - self.activeWrite = nil + guard let self else { return } + let event: DebugTransportEvent switch result { - case .success: - self.bufferedBytes -= self.writes.removeFirst().count - if self.draining, self.writes.isEmpty { self.cleanUp(); return } - // Do not recurse if a provider completes writes synchronously. - Task { @MainActor [weak self] in self?.sendNext(token: token) } - case .failure(let error): self.finish(error: error) + case .success: event = .writeCompleted(generation: generation, writeID: writeID) + case .failure(let error): event = .failed(generation: generation, message: error.localizedDescription) + } + // A provider may complete inline; never recurse through the queue. + if effectDepth > 0 { + Task { @MainActor [weak self] in self?.dispatch(event) } + } else { + dispatch(event) } } } - private func finish(error: Error?) { - guard running else { return } - if draining { cleanUp(); return } - // Invalidate before calling native stop or client callbacks: both can reenter. - cleanUp() - let finishedGeneration = generation - if let error { onErrorOutput?(Data((error.localizedDescription + "\n").utf8)) } - // An error observer can stop or restart the owner synchronously. Never - // deliver the old termination into that replacement lifecycle. - guard generation == finishedGeneration else { return } - onTermination?(error == nil ? 0 : 1) - } - - private func cleanUp() { - generation = UUID() - running = false - draining = false - drainDeadline?.cancel() - drainDeadline = nil - startupDeadline?.cancel() - startupDeadline = nil - writeDeadline?.cancel() - writeDeadline = nil + /// Releases the socket, the discovery task, the queued frames and every timer + /// still armed. Detach the callbacks first: native stop can reenter. + private func closeConnection() { startupTask?.cancel() startupTask = nil + for deadline in timers.values { deadline.cancel() } + timers.removeAll() let connection = socket socket = nil connection?.onReady = nil @@ -244,8 +285,18 @@ final class MacTCPDebugAdapterTransport: DebugAdapterDrainingTransport { connection?.onComplete = nil connection?.stop() writes = [] - bufferedBytes = 0 - activeWrite = nil - ready = false + } + + /// Core could not be reached. Fall back to a local, silent-to-Core teardown so + /// the socket and timers do not outlive the failure, then tell the owner once. + private func abort(after error: Error) { + let wasRunning = isRunning + phase = .stopped + generation &+= 1 + closeConnection() + try? core.releaseDebugTransport(transportID: transportID) + guard wasRunning else { return } + onErrorOutput?(Data((error.localizedDescription + "\n").utf8)) + onTermination?(1) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index db8a3435fd..fa32f95e59 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -622,7 +622,8 @@ final class MacServiceContainer { return try await capability.sessions.startJavaDebugServer( rootURL: rootURL ) - } + }, + core: rustCore ), core: rustCore, deadlineScheduler: MacDebugOperationDeadlineScheduler() @@ -652,6 +653,7 @@ final class MacServiceContainer { ) }, makeProcess: { MacRawProcessSession() }, + core: rustCore, findCommand: { commands in commands.lazy.compactMap { command in runtimeService.executableCandidates(command) @@ -710,16 +712,17 @@ final class MacServiceContainer { } catch let error as RustCoreBridge.CoreCallError where error.code == "invalid_request" { throw MacTCPDebugAdapterTransport.TransportError.invalidEndpoint } - } + }, + core: rustCore ), core: rustCore, deadlineScheduler: MacDebugOperationDeadlineScheduler(), - // The session clock starts before the socket exists, so it must - // outlast the transport's connect budget and still leave a full - // DAP handshake window; the transport deadline then reports a + // The session clock starts before the socket exists, so Core sizes + // it to outlast the transport's connect budget and still leave a + // full DAP handshake window; the transport deadline then reports a // slow connect as itself, not as a failed `initialize`. initializationTimeoutMilliseconds: - MacTCPDebugAdapterTransport.Limits().connectMilliseconds + 10_000 + try rustCore.debugTransportPolicy().initializationMilliseconds ) if let entry { debugAdapterSessionsBox.record(session, pluginID: entry.plugin.descriptor.id.rawValue) diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index 7eee1f4dae..7f0baf5bbc 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -47,6 +47,7 @@ struct PluginDebugAdapterTests { adapterID: "lldb", resolve: { _ in Self.process(.commands(["lldb-dap"])) }, makeProcess: { Issue.record("No process starts for a missing command."); fatalError() }, + core: RustCoreBridge(), findCommand: { _ in nil } ) #expect(throws: MacPluginDebugAdapterTransport.TransportError.commandNotFound(adapterID: "lldb", commands: ["lldb-dap"])) { @@ -60,7 +61,8 @@ struct PluginDebugAdapterTests { let transport = MacPluginDebugAdapterTransport( adapterID: "lldb", resolve: { _ in throw ResolveFailure() }, - makeProcess: { Issue.record("No process starts when the plan is rejected."); fatalError() } + makeProcess: { Issue.record("No process starts when the plan is rejected."); fatalError() }, + core: RustCoreBridge() ) #expect(throws: ResolveFailure()) { try transport.start(rootURL: URL(fileURLWithPath: "/tmp/workspace", isDirectory: true)) @@ -273,6 +275,7 @@ struct PluginDebugAdapterTests { adapterID: "example", resolve: { _ in Self.hostedLaunch() }, makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() }, + core: RustCoreBridge(), startHostedPort: { languageID, command, rootURL in probe.recordPortRequest(languageID: languageID, command: command, rootURL: rootURL) return 4711 @@ -311,6 +314,7 @@ struct PluginDebugAdapterTests { adapterID: "example", resolve: { _ in Self.hostedLaunch() }, makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() }, + core: RustCoreBridge(), startHostedPort: { _, _, _ in throw Unavailable() }, socketFactory: { host, port in Issue.record("No socket opens without a port.") @@ -338,7 +342,8 @@ struct PluginDebugAdapterTests { let transport = MacPluginDebugAdapterTransport( adapterID: "example", resolve: { _ in Self.hostedLaunch() }, - makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() } + makeProcess: { Issue.record("A hosted adapter starts no process of its own."); fatalError() }, + core: RustCoreBridge() ) defer { transport.stop() } transport.onErrorOutput = { probe.recordErrorOutput($0) } diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index 6572be6e2c..e99dc823a3 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -108,7 +108,8 @@ struct RealJavaDebugIntegrationTests { wrapping: MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await languageManager.startJavaDebugServer(rootURL: rootURL) - } + }, + core: core ), trace: protocolTrace ), @@ -415,7 +416,8 @@ struct RealJavaDebugIntegrationTests { wrapping: MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await languageManager.startJavaDebugServer(rootURL: rootURL) - } + }, + core: core ), trace: protocolTrace ), diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 8a54b17c17..3a6a50a234 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1219,6 +1219,7 @@ struct RunConfigurationIntegrationTests { let endpointRecorder = DebugEndpointRecorder() let transport = MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) }, + core: RustCoreBridge(), socketFactory: { host, port in endpointRecorder.record(host: host, port: port) return socket @@ -1252,6 +1253,7 @@ struct RunConfigurationIntegrationTests { let endpointRecorder = DebugEndpointRecorder() let transport = MacHostedPortDebugAdapterTransport( portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) }, + core: RustCoreBridge(), socketFactory: { host, port in endpointRecorder.record(host: host, port: port) return socket diff --git a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift index 9f261d4a4e..2dfa0dd6e6 100644 --- a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift @@ -18,7 +18,7 @@ struct TCPDebugAdapterTests { let scheduler = TCPTestScheduler() var sockets = [first, second] let transport = MacTCPDebugAdapterTransport( - endpointResolver: { _ in endpoint }, deadlineScheduler: scheduler, + endpointResolver: { _ in endpoint }, core: RustCoreBridge(), deadlineScheduler: scheduler, socketFactory: { _ in sockets.removeFirst() } ) defer { transport.stop() } @@ -113,7 +113,8 @@ struct TCPDebugAdapterTests { let second = ControlledDebugSocket() var sockets = [first, second] let transport = MacTCPDebugAdapterTransport( - endpointResolver: { _ in endpoint }, socketFactory: { _ in sockets.removeFirst() } + endpointResolver: { _ in endpoint }, core: RustCoreBridge(), + socketFactory: { _ in sockets.removeFirst() } ) defer { transport.onErrorOutput = nil; transport.stop() } transport.onErrorOutput = { _ in @@ -169,6 +170,7 @@ struct TCPDebugAdapterTests { let socket = ControlledDebugSocket() let transport = MacTCPDebugAdapterTransport( endpointResolver: { _ in connecting ? endpoint : try await gate.resolve() }, + core: RustCoreBridge(), socketFactory: { _ in socket } ) defer { transport.stop(); gate.finish(endpoint) } @@ -192,7 +194,7 @@ struct TCPDebugAdapterTests { let scheduler = TCPTestScheduler() let ended = TCPTestEvent() let transport = MacTCPDebugAdapterTransport( - endpointResolver: { _ in endpoint }, deadlineScheduler: scheduler, + endpointResolver: { _ in endpoint }, core: RustCoreBridge(), deadlineScheduler: scheduler, socketFactory: { _ in throw TCPTestError.failed } ) defer { transport.stop() } @@ -212,6 +214,7 @@ struct TCPDebugAdapterTests { endpoint: .init(host: "127.0.0.1", port: 5678), validate: { .init(host: $0.host, port: $0.port, remote: false) }, confirm: { _ in Issue.record("Loopback prompted for remote consent"); return false }, + core: RustCoreBridge(), connectionProvider: provider ) defer { transport.stop() } @@ -287,6 +290,7 @@ struct TCPDebugAdapterTests { var opened = 0 let transport = MacTCPDebugAdapterTransport( endpointResolver: { _ in try await gate.resolve() }, + core: RustCoreBridge(), deadlineScheduler: scheduler, socketFactory: { _ in opened += 1; return ControlledDebugSocket() } ) @@ -406,6 +410,7 @@ struct TCPDebugAdapterTests { var sockets = [first, second] let transport = MacTCPDebugAdapterTransport( endpointResolver: { _ in endpoint }, + core: RustCoreBridge(), socketFactory: { _ in sockets.removeFirst() } ) defer { transport.stop() } @@ -453,6 +458,7 @@ struct TCPDebugAdapterTests { #expect(input == endpoint) return approved }, + core: RustCoreBridge(), connectionProvider: provider ) } @@ -481,6 +487,7 @@ struct TCPDebugAdapterTests { endpoint: endpoint, validate: { _ in throw TCPTestError.failed }, confirm: { _ in Issue.record("Invalid endpoint reached consent"); return true }, + core: RustCoreBridge(), connectionProvider: provider ) } @@ -495,7 +502,7 @@ struct TCPDebugAdapterTests { let port = try #require(server.port) let transport = MacTCPDebugAdapterTransport(endpointResolver: { _ in DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) - }) + }, core: RustCoreBridge()) defer { transport.stop() } let received = TCPTestEvent() var bytes = Data() @@ -516,7 +523,7 @@ struct TCPDebugAdapterTests { let port = try Self.unusedLoopbackPort() let transport = MacTCPDebugAdapterTransport(endpointResolver: { _ in DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) - }) + }, core: RustCoreBridge()) defer { transport.stop() } let ended = TCPTestEvent() var codes: [Int] = [] @@ -538,7 +545,7 @@ struct TCPDebugAdapterTests { let port = try #require(server.port) let transport = MacTCPDebugAdapterTransport(endpointResolver: { _ in DebugAdapterEndpoint(host: "127.0.0.1", port: Int(port)) - }) + }, core: RustCoreBridge()) defer { transport.stop() } let session = CoreDebugAdapterProtocolSession( adapterID: "python", transport: transport, core: RustCoreBridge(), @@ -626,7 +633,7 @@ struct TCPDebugAdapterTests { limits: MacTCPDebugAdapterTransport.Limits = .init() ) -> MacTCPDebugAdapterTransport { MacTCPDebugAdapterTransport( - endpointResolver: { _ in endpoint }, deadlineScheduler: scheduler, + endpointResolver: { _ in endpoint }, core: RustCoreBridge(), deadlineScheduler: scheduler, limits: limits, socketFactory: { _ in socket } ) } diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index c49020ab9a..c90f841630 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -8,12 +8,14 @@ mod engine; mod java_test; mod projection_v2; mod protocol; +mod transport_lifecycle; mod types; pub(crate) use breakpoint_relocation::*; pub(crate) use endpoint::*; pub(crate) use engine::*; pub(crate) use java_test::*; +pub(crate) use transport_lifecycle::*; pub(crate) use types::*; /// Prevents DevTools from restarting the application while a debugger replaces classes. diff --git a/rust/lithe-core/src/debug/transport_lifecycle.rs b/rust/lithe-core/src/debug/transport_lifecycle.rs new file mode 100644 index 0000000000..1420e479cf --- /dev/null +++ b/rust/lithe-core/src/debug/transport_lifecycle.rs @@ -0,0 +1,902 @@ +//! Platform-neutral lifecycle rules for one IDE-to-adapter byte stream. +//! +//! This is a reducer, separate from the DAP protocol state in `engine.rs`: the +//! platform reports what happened (an endpoint resolved, a write finished, a +//! timer fired) and Core answers with the effects to run next (connect, write, +//! arm a timer, close). Core never reads a clock, opens a socket or holds the +//! bytes. Hosts keep the real socket, DNS, timers and queued `Data`; only sizes +//! and counts cross the JSON boundary. That keeps one rule set, one deadline +//! policy and one set of lifecycle tests for every platform network layer. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Mutex, OnceLock}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::protocol::{CoreError, ErrorCode}; + +/// Endpoint discovery, such as a cold language-server start. +const DEFAULT_DISCOVERY_MILLISECONDS: u64 = 10_000; +/// Establishing the socket once the endpoint is known. +const DEFAULT_CONNECT_MILLISECONDS: u64 = 10_000; +/// One outgoing write against a stalled peer, never the length of a debug session. +const DEFAULT_WRITE_MILLISECONDS: u64 = 10_000; +const DEFAULT_BUFFERED_BYTES: u64 = 8 * 1024 * 1024; +const DEFAULT_BUFFERED_WRITES: u64 = 1_024; +/// Best-effort flush of queued frames, including `disconnect`, before closing. +const DEFAULT_DRAIN_MILLISECONDS: u64 = 1_000; +/// Window a DAP `initialize` round trip gets once the socket is connected. +const DAP_HANDSHAKE_MILLISECONDS: u64 = 10_000; +/// Keeps host-supplied limits inside what every platform integer can hold. +const MAX_LIMIT: u64 = i32::MAX as u64; + +static TRANSPORTS: OnceLock>> = OnceLock::new(); + +/// Deadline and queue policy for one transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct TransportLimits { + discovery_milliseconds: u64, + connect_milliseconds: u64, + write_milliseconds: u64, + buffered_bytes: u64, + buffered_writes: u64, + drain_milliseconds: u64, +} + +impl Default for TransportLimits { + fn default() -> Self { + Self { + discovery_milliseconds: DEFAULT_DISCOVERY_MILLISECONDS, + connect_milliseconds: DEFAULT_CONNECT_MILLISECONDS, + write_milliseconds: DEFAULT_WRITE_MILLISECONDS, + buffered_bytes: DEFAULT_BUFFERED_BYTES, + buffered_writes: DEFAULT_BUFFERED_WRITES, + drain_milliseconds: DEFAULT_DRAIN_MILLISECONDS, + } + } +} + +/// Per-transport overrides; an absent field keeps the Core default. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LimitOverrides { + discovery_milliseconds: Option, + connect_milliseconds: Option, + write_milliseconds: Option, + buffered_bytes: Option, + buffered_writes: Option, + drain_milliseconds: Option, +} + +impl TransportLimits { + fn resolve(overrides: Option) -> Result { + let overrides = overrides.unwrap_or_default(); + let defaults = Self::default(); + let pick = |value: Option, default: u64, field: &str| match value { + None => Ok(default), + Some(value) if (1..=MAX_LIMIT).contains(&value) => Ok(value), + Some(_) => Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Debug transport {field} must be between 1 and {MAX_LIMIT}."), + )), + }; + Ok(Self { + discovery_milliseconds: pick( + overrides.discovery_milliseconds, + defaults.discovery_milliseconds, + "discoveryMilliseconds", + )?, + connect_milliseconds: pick( + overrides.connect_milliseconds, + defaults.connect_milliseconds, + "connectMilliseconds", + )?, + write_milliseconds: pick( + overrides.write_milliseconds, + defaults.write_milliseconds, + "writeMilliseconds", + )?, + buffered_bytes: pick( + overrides.buffered_bytes, + defaults.buffered_bytes, + "bufferedBytes", + )?, + buffered_writes: pick( + overrides.buffered_writes, + defaults.buffered_writes, + "bufferedWrites", + )?, + drain_milliseconds: pick( + overrides.drain_milliseconds, + defaults.drain_milliseconds, + "drainMilliseconds", + )?, + }) + } +} + +/// Where a transport is in its life. `Stopped` is both the initial and the final phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum TransportPhase { + Stopped, + /// The host is looking for the endpoint, for example by starting a language server. + Discovering, + /// The endpoint is known and the host is establishing the socket. + Connecting, + Ready, + /// The owner asked to stop: queued frames flush, new data and late messages are refused. + Draining, +} + +/// Events the platform reports or asks for. Callback events carry the `generation` +/// the host captured when it started the work they answer; user-driven events +/// (`start`, `send`, `stop`, `finishSendingAndStop`) always address the live lifecycle. +#[derive(Debug, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum TransportEvent { + /// Begins a lifecycle; ignored while one is already running. + Start { + limits: Option, + }, + /// Queues one outgoing frame of `bytes` bytes. The host keeps the bytes. + Send { + bytes: u64, + }, + /// User cancel: tears everything down silently, without a termination. + Stop {}, + /// Flushes queued frames for a bounded time, then closes silently. + FinishSendingAndStop {}, + /// Endpoint discovery finished. `port` stays signed so a bad provider value is + /// a transport failure rather than a malformed request. + EndpointResolved { + generation: u64, + host: String, + port: i64, + }, + ConnectionReady { + generation: u64, + }, + /// Bytes arrived; Core decides whether the host may forward them. + DataReceived { + generation: u64, + }, + /// The peer closed the stream (EOF). + PeerClosed { + generation: u64, + }, + /// Discovery, connect, socket or write failed. `message` is upstream diagnostics. + Failed { + generation: u64, + message: String, + }, + WriteCompleted { + generation: u64, + write_id: u64, + }, + TimerFired { + generation: u64, + timer_id: u64, + }, +} + +/// Why a timer was armed, so a host can log or label it. Core matches on `timerId`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum TimerPurpose { + Discovery, + Connect, + Write, + Drain, +} + +/// Stable failure categories. The host owns the localized text for each. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum FailureReason { + /// Discovery or connect exceeded its deadline. + StartupTimedOut, + /// The peer did not accept the in-flight write in time. + WriteTimedOut, + /// The outgoing queue exceeded its byte or count budget. + BufferLimit, + /// The resolved endpoint has no host or its port is outside 1-65535. + InvalidEndpoint, + /// The host reported the failure; the effect carries its own message. + Host, +} + +/// Work the host performs, in order, after a step. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum TransportEffect { + /// Start looking for the endpoint and report `endpointResolved` or `failed`. + DiscoverEndpoint, + ArmTimer { + timer_id: u64, + purpose: TimerPurpose, + after_milliseconds: u64, + }, + CancelTimer { + timer_id: u64, + }, + /// Open the socket and report `connectionReady`, `dataReceived`, `peerClosed` or `failed`. + Connect { + host: String, + port: u16, + }, + /// Send the oldest queued frame, which must be `bytes` long, then report `writeCompleted`. + Write { + write_id: u64, + bytes: u64, + }, + /// Forward the received bytes to the owner. + DeliverData, + /// Cancel pending discovery, unhook and stop the socket, drop queued frames. + Close, + /// Surface the failure to the owner's error output before termination. + ReportFailure { + reason: FailureReason, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + /// Tell the owner the stream ended: exit code 0 for a clean EOF, 1 after a failure. + NotifyTermination { + exit_code: i32, + }, +} + +/// What the step did with the event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum StepOutcome { + Applied, + /// A stale callback, a repeat or a no-op; nothing changed. + Ignored, + /// `send` on a transport that is not running; the host throws "stopped". + RejectedNotRunning, + /// `send` over budget; Core already failed the transport, the host throws "buffer full". + RejectedBufferLimit, +} + +/// Core's answer to one event. +/// +/// A host that re-enters Core while running `effects` (an observer restarting the +/// transport) must compare the live generation with this one and abandon the rest +/// of an older response. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct TransportStep { + /// Generation after the step; callbacks of the new work must carry it. + generation: u64, + phase: TransportPhase, + outcome: StepOutcome, + effects: Vec, +} + +/// A failure that ends the lifecycle with exit code 1. +enum Failure { + Reason(FailureReason), + Host(String), +} + +#[derive(Debug)] +struct Lifecycle { + limits: TransportLimits, + /// Bumped whenever work is invalidated, so any older callback is recognizably stale. + generation: u64, + phase: TransportPhase, + /// Sizes of queued frames, oldest first; the host holds the matching bytes. + queued: VecDeque, + buffered_bytes: u64, + /// At most one write is in flight, and it is always the head of `queued`. + active_write: Option, + /// Discovery, then connect, deadline; cancelled once the stream is ready. + startup_timer: Option, + write_timer: Option, + drain_timer: Option, + next_timer_id: u64, + next_write_id: u64, +} + +impl Lifecycle { + fn new() -> Self { + Self { + limits: TransportLimits::default(), + generation: 0, + phase: TransportPhase::Stopped, + queued: VecDeque::new(), + buffered_bytes: 0, + active_write: None, + startup_timer: None, + write_timer: None, + drain_timer: None, + next_timer_id: 1, + next_write_id: 1, + } + } + + fn handle(&mut self, event: TransportEvent) -> Result { + let mut effects = Vec::new(); + let outcome = match event { + TransportEvent::Start { limits } => { + let limits = TransportLimits::resolve(limits)?; + self.start(limits, &mut effects) + } + TransportEvent::Send { bytes } => self.send(bytes, &mut effects), + TransportEvent::Stop {} if self.phase == TransportPhase::Stopped => { + StepOutcome::Ignored + } + TransportEvent::Stop {} => { + self.clean_up(&mut effects); + StepOutcome::Applied + } + TransportEvent::FinishSendingAndStop {} => self.finish_sending_and_stop(&mut effects), + TransportEvent::EndpointResolved { + generation, + host, + port, + } => self.endpoint_resolved(generation, host, port, &mut effects), + TransportEvent::ConnectionReady { generation } => { + self.connection_ready(generation, &mut effects) + } + TransportEvent::DataReceived { generation } => { + self.data_received(generation, &mut effects) + } + TransportEvent::PeerClosed { generation } => { + self.report(generation, None, &mut effects) + } + TransportEvent::Failed { + generation, + message, + } => self.report(generation, Some(Failure::Host(message)), &mut effects), + TransportEvent::WriteCompleted { + generation, + write_id, + } => self.write_completed(generation, write_id, &mut effects), + TransportEvent::TimerFired { + generation, + timer_id, + } => self.timer_fired(generation, timer_id, &mut effects), + }; + Ok(TransportStep { + generation: self.generation, + phase: self.phase, + outcome, + effects, + }) + } + + /// A callback is current only while the lifecycle it was started under is alive. + fn is_current(&self, generation: u64) -> bool { + self.phase != TransportPhase::Stopped && self.generation == generation + } + + fn start( + &mut self, + limits: TransportLimits, + effects: &mut Vec, + ) -> StepOutcome { + // A draining lifecycle has already been released by its owner, so a new + // start replaces it instead of waiting for the flush. + if self.phase == TransportPhase::Draining { + self.clean_up(effects); + } + if self.phase != TransportPhase::Stopped { + return StepOutcome::Ignored; + } + self.limits = limits; + self.generation += 1; + self.phase = TransportPhase::Discovering; + self.arm_startup_timer( + TimerPurpose::Discovery, + limits.discovery_milliseconds, + effects, + ); + effects.push(TransportEffect::DiscoverEndpoint); + StepOutcome::Applied + } + + fn send(&mut self, bytes: u64, effects: &mut Vec) -> StepOutcome { + if !matches!( + self.phase, + TransportPhase::Discovering | TransportPhase::Connecting | TransportPhase::Ready + ) { + return StepOutcome::RejectedNotRunning; + } + if bytes == 0 { + return StepOutcome::Applied; + } + let room = self + .limits + .buffered_bytes + .saturating_sub(self.buffered_bytes); + if self.queued.len() as u64 >= self.limits.buffered_writes || bytes > room { + self.finish(Some(Failure::Reason(FailureReason::BufferLimit)), effects); + return StepOutcome::RejectedBufferLimit; + } + self.queued.push_back(bytes); + self.buffered_bytes += bytes; + self.send_next(effects); + StepOutcome::Applied + } + + fn finish_sending_and_stop(&mut self, effects: &mut Vec) -> StepOutcome { + if matches!( + self.phase, + TransportPhase::Stopped | TransportPhase::Draining + ) { + return StepOutcome::Ignored; + } + // Nothing can flush before the stream is ready or when nothing is queued. + if self.phase != TransportPhase::Ready || self.queued.is_empty() { + self.clean_up(effects); + return StepOutcome::Applied; + } + self.phase = TransportPhase::Draining; + let timer_id = self.arm_timer(TimerPurpose::Drain, self.limits.drain_milliseconds, effects); + self.drain_timer = Some(timer_id); + self.send_next(effects); + StepOutcome::Applied + } + + fn endpoint_resolved( + &mut self, + generation: u64, + host: String, + port: i64, + effects: &mut Vec, + ) -> StepOutcome { + if !self.is_current(generation) || self.phase != TransportPhase::Discovering { + return StepOutcome::Ignored; + } + // User endpoints are validated before this point; a provider can still hand + // back anything, and every platform needs a safe native port conversion. + let Some(port) = u16::try_from(port).ok().filter(|port| *port != 0) else { + self.finish( + Some(Failure::Reason(FailureReason::InvalidEndpoint)), + effects, + ); + return StepOutcome::Applied; + }; + if host.is_empty() { + self.finish( + Some(Failure::Reason(FailureReason::InvalidEndpoint)), + effects, + ); + return StepOutcome::Applied; + } + self.phase = TransportPhase::Connecting; + // Discovery is over; connecting gets its own budget instead of the remainder. + self.arm_startup_timer( + TimerPurpose::Connect, + self.limits.connect_milliseconds, + effects, + ); + effects.push(TransportEffect::Connect { host, port }); + StepOutcome::Applied + } + + fn connection_ready( + &mut self, + generation: u64, + effects: &mut Vec, + ) -> StepOutcome { + if !self.is_current(generation) || self.phase != TransportPhase::Connecting { + return StepOutcome::Ignored; + } + self.phase = TransportPhase::Ready; + cancel_timer(&mut self.startup_timer, effects); + self.send_next(effects); + StepOutcome::Applied + } + + fn data_received( + &mut self, + generation: u64, + effects: &mut Vec, + ) -> StepOutcome { + // Draining refuses late messages: the owner has already released its session. + if self.is_current(generation) + && matches!( + self.phase, + TransportPhase::Connecting | TransportPhase::Ready + ) + { + effects.push(TransportEffect::DeliverData); + StepOutcome::Applied + } else { + StepOutcome::Ignored + } + } + + /// Handles EOF (`failure` is `None`) and host-reported errors alike. + fn report( + &mut self, + generation: u64, + failure: Option, + effects: &mut Vec, + ) -> StepOutcome { + if !self.is_current(generation) { + return StepOutcome::Ignored; + } + self.finish(failure, effects); + StepOutcome::Applied + } + + fn write_completed( + &mut self, + generation: u64, + write_id: u64, + effects: &mut Vec, + ) -> StepOutcome { + if !self.is_current(generation) || self.active_write != Some(write_id) { + return StepOutcome::Ignored; + } + cancel_timer(&mut self.write_timer, effects); + self.active_write = None; + if let Some(bytes) = self.queued.pop_front() { + self.buffered_bytes = self.buffered_bytes.saturating_sub(bytes); + } + if self.phase == TransportPhase::Draining && self.queued.is_empty() { + self.clean_up(effects); + } else { + self.send_next(effects); + } + StepOutcome::Applied + } + + fn timer_fired( + &mut self, + generation: u64, + timer_id: u64, + effects: &mut Vec, + ) -> StepOutcome { + if !self.is_current(generation) { + return StepOutcome::Ignored; + } + // Match on the armed ids so a timer the host failed to cancel stays harmless. + if self.startup_timer == Some(timer_id) { + self.startup_timer = None; + self.finish( + Some(Failure::Reason(FailureReason::StartupTimedOut)), + effects, + ); + } else if self.write_timer == Some(timer_id) { + self.write_timer = None; + self.finish(Some(Failure::Reason(FailureReason::WriteTimedOut)), effects); + } else if self.drain_timer == Some(timer_id) { + self.drain_timer = None; + self.clean_up(effects); + } else { + return StepOutcome::Ignored; + } + StepOutcome::Applied + } + + /// Starts the next write unless one is already in flight. + fn send_next(&mut self, effects: &mut Vec) { + if !matches!(self.phase, TransportPhase::Ready | TransportPhase::Draining) + || self.active_write.is_some() + { + return; + } + let Some(&bytes) = self.queued.front() else { + return; + }; + let write_id = self.next_write_id; + self.next_write_id += 1; + self.active_write = Some(write_id); + let timer_id = self.arm_timer(TimerPurpose::Write, self.limits.write_milliseconds, effects); + self.write_timer = Some(timer_id); + effects.push(TransportEffect::Write { write_id, bytes }); + } + + /// Ends the lifecycle once. A draining lifecycle ends silently because its + /// owner already released the session; otherwise the owner hears the failure + /// (if any) and then the termination. + fn finish(&mut self, failure: Option, effects: &mut Vec) { + if self.phase == TransportPhase::Stopped { + return; + } + let silent = self.phase == TransportPhase::Draining; + // Invalidate first: the host's close and observer callbacks can re-enter. + self.clean_up(effects); + if silent { + return; + } + let exit_code = if failure.is_some() { 1 } else { 0 }; + match failure { + Some(Failure::Reason(reason)) => effects.push(TransportEffect::ReportFailure { + reason, + message: None, + }), + Some(Failure::Host(message)) => effects.push(TransportEffect::ReportFailure { + reason: FailureReason::Host, + message: Some(message), + }), + None => {} + } + effects.push(TransportEffect::NotifyTermination { exit_code }); + } + + /// Idempotent teardown that also invalidates every outstanding callback. + fn clean_up(&mut self, effects: &mut Vec) { + if self.phase == TransportPhase::Stopped { + return; + } + self.generation += 1; + self.phase = TransportPhase::Stopped; + cancel_timer(&mut self.drain_timer, effects); + cancel_timer(&mut self.startup_timer, effects); + cancel_timer(&mut self.write_timer, effects); + self.queued.clear(); + self.buffered_bytes = 0; + self.active_write = None; + effects.push(TransportEffect::Close); + } + + fn arm_startup_timer( + &mut self, + purpose: TimerPurpose, + after_milliseconds: u64, + effects: &mut Vec, + ) { + cancel_timer(&mut self.startup_timer, effects); + let timer_id = self.arm_timer(purpose, after_milliseconds, effects); + self.startup_timer = Some(timer_id); + } + + fn arm_timer( + &mut self, + purpose: TimerPurpose, + after_milliseconds: u64, + effects: &mut Vec, + ) -> u64 { + let timer_id = self.next_timer_id; + self.next_timer_id += 1; + effects.push(TransportEffect::ArmTimer { + timer_id, + purpose, + after_milliseconds, + }); + timer_id + } +} + +fn cancel_timer(slot: &mut Option, effects: &mut Vec) { + if let Some(timer_id) = slot.take() { + effects.push(TransportEffect::CancelTimer { timer_id }); + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TransportEventRequest { + transport_id: String, + event: TransportEvent, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TransportRequest { + transport_id: String, +} + +fn transports_lock() -> Result>, CoreError> +{ + TRANSPORTS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Debug transport state is unavailable.")) +} + +fn parse Deserialize<'de>>(payload: Value) -> Result { + serde_json::from_value(payload).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug transport request.", + ) + .with_details(error.to_string()) + }) +} + +fn validate_transport_id(transport_id: &str) -> Result<(), CoreError> { + if transport_id.trim().is_empty() || transport_id.contains('\0') || transport_id.len() > 512 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Debug transportId was invalid.", + )); + } + Ok(()) +} + +/// Returns the default deadlines and budgets, and the session `initialize` +/// deadline that must outlast a connect, without creating any state. +pub(crate) fn transport_policy(_payload: Value) -> Result { + let limits = TransportLimits::default(); + Ok(json!({ + "limits": limits, + "initializationMilliseconds": limits.connect_milliseconds + DAP_HANDSHAKE_MILLISECONDS, + })) +} + +/// Applies one event to a transport's lifecycle and returns the effects to run. +/// Only `start` creates a transport; a late callback for a released one is stale. +pub(crate) fn handle_transport_event(payload: Value) -> Result { + let request: TransportEventRequest = parse(payload)?; + validate_transport_id(&request.transport_id)?; + let mut transports = transports_lock()?; + let step = match transports.get_mut(&request.transport_id) { + Some(lifecycle) => lifecycle.handle(request.event)?, + None if matches!(request.event, TransportEvent::Start { .. }) => { + let mut lifecycle = Lifecycle::new(); + let step = lifecycle.handle(request.event)?; + transports.insert(request.transport_id, lifecycle); + step + } + None => TransportStep { + generation: 0, + phase: TransportPhase::Stopped, + outcome: if matches!(request.event, TransportEvent::Send { .. }) { + StepOutcome::RejectedNotRunning + } else { + StepOutcome::Ignored + }, + effects: Vec::new(), + }, + }; + Ok(serde_json::to_value(step).expect("Transport step should encode")) +} + +/// Forgets a transport once the host has stopped it and dropped its handles. +pub(crate) fn release_transport(payload: Value) -> Result { + let request: TransportRequest = parse(payload)?; + validate_transport_id(&request.transport_id)?; + let released = transports_lock()?.remove(&request.transport_id).is_some(); + Ok(json!({ "released": released })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn step(lifecycle: &mut Lifecycle, event: Value) -> Value { + let event: TransportEvent = serde_json::from_value(event).expect("event decodes"); + serde_json::to_value(lifecycle.handle(event).expect("event is valid")) + .expect("step encodes") + } + + /// Replays every scripted scenario against a fresh lifecycle. The same file is + /// the contract any platform adapter's tests can reuse. + #[test] + fn lifecycle_fixture_scenarios_match_expected_steps() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/transport-lifecycle-v1.json" + ))) + .expect("transport lifecycle fixture is JSON"); + for scenario in fixture["scenarios"].as_array().expect("scenarios") { + let mut lifecycle = Lifecycle::new(); + for (index, scripted) in scenario["steps"] + .as_array() + .expect("scenario steps") + .iter() + .enumerate() + { + let actual = step(&mut lifecycle, scripted["event"].clone()); + assert_eq!( + actual, scripted["expect"], + "{} step {index}: {}", + scenario["name"], scripted["event"] + ); + } + } + } + + #[test] + fn invalid_limit_overrides_are_rejected_before_any_state_changes() { + let mut lifecycle = Lifecycle::new(); + for limits in [ + json!({"writeMilliseconds": 0}), + json!({"bufferedBytes": 0}), + json!({"discoveryMilliseconds": MAX_LIMIT + 1}), + ] { + let event: TransportEvent = + serde_json::from_value(json!({"type": "start", "limits": limits})).unwrap(); + let error = lifecycle.handle(event).expect_err("invalid limits"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + assert_eq!(lifecycle.phase, TransportPhase::Stopped); + assert_eq!(lifecycle.generation, 0); + } + + #[test] + fn unknown_event_fields_and_types_are_invalid_requests() { + for payload in [ + json!({"transportId": "t", "event": {"type": "nope"}}), + json!({"transportId": "t", "event": {"type": "stop", "extra": 1}}), + json!({"transportId": "", "event": {"type": "stop"}}), + json!({"transportId": "t"}), + ] { + let error = handle_transport_event(payload).expect_err("invalid request"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + } + + #[test] + fn json_boundary_creates_on_start_and_treats_released_transports_as_stale() { + let id = "transport-lifecycle-json-boundary"; + let before = handle_transport_event( + json!({"transportId": id, "event": {"type": "send", "bytes": 1}}), + ) + .unwrap(); + assert_eq!(before["outcome"], "rejectedNotRunning"); + + let started = + handle_transport_event(json!({"transportId": id, "event": {"type": "start"}})).unwrap(); + assert_eq!(started["generation"], 1); + assert_eq!(started["phase"], "discovering"); + + assert_eq!( + release_transport(json!({"transportId": id})).unwrap()["released"], + true + ); + assert_eq!( + release_transport(json!({"transportId": id})).unwrap()["released"], + false + ); + // A callback that outlives its transport must not resurrect it. + let late = handle_transport_event(json!({ + "transportId": id, + "event": {"type": "connectionReady", "generation": 1} + })) + .unwrap(); + assert_eq!(late["outcome"], "ignored"); + assert_eq!(late["phase"], "stopped"); + } + + #[test] + fn policy_reports_core_defaults_and_an_initialization_deadline_past_connect() { + let policy = transport_policy(json!({})).unwrap(); + assert_eq!(policy["limits"]["discoveryMilliseconds"], 10_000); + assert_eq!(policy["limits"]["connectMilliseconds"], 10_000); + assert_eq!(policy["limits"]["writeMilliseconds"], 10_000); + assert_eq!(policy["limits"]["bufferedBytes"], 8 * 1024 * 1024); + assert_eq!(policy["limits"]["bufferedWrites"], 1_024); + assert_eq!(policy["limits"]["drainMilliseconds"], 1_000); + assert_eq!(policy["initializationMilliseconds"], 20_000); + } + + #[test] + fn a_cancelled_timer_the_host_still_fires_cannot_fail_a_ready_stream() { + // The host may race a timer callback with the cancel effect. + let mut lifecycle = Lifecycle::new(); + step(&mut lifecycle, json!({"type": "start"})); + step( + &mut lifecycle, + json!({"type": "endpointResolved", "generation": 1, "host": "127.0.0.1", "port": 5005}), + ); + step( + &mut lifecycle, + json!({"type": "connectionReady", "generation": 1}), + ); + let late = step( + &mut lifecycle, + json!({"type": "timerFired", "generation": 1, "timerId": 2}), + ); + assert_eq!(late["outcome"], "ignored"); + assert_eq!(late["phase"], "ready"); + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 3f43a60069..e83ecf3d2f 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -88,6 +88,13 @@ pub enum CoreCommand { DebugValidateAdapterEndpoint, /// Says where to look for the program a launch debugs (`debug.adapters.executablePlan`). DebugAdaptersExecutablePlan, + /// Returns the default byte-stream deadlines and queue budgets (`debug.transport.policy`). + DebugTransportPolicy, + /// Applies one platform event to a byte-stream lifecycle and returns the effects to run + /// (`debug.transport.handle`). + DebugTransportHandle, + /// Forgets the lifecycle of a stopped byte stream (`debug.transport.release`). + DebugTransportRelease, /// Compares a launch runtime's version output with its requirement (`plugins.declarative.checkRuntimeVersion`). PluginsDeclarativeCheckRuntimeVersion, /// Merges a plugin's stored setting layers into effective values (`plugins.declarative.resolveSettings`). @@ -513,6 +520,9 @@ impl CoreCommand { "debug.adapters.startArguments" => Some(Self::DebugAdaptersStartArguments), "debug.validateAdapterEndpoint" => Some(Self::DebugValidateAdapterEndpoint), "debug.adapters.executablePlan" => Some(Self::DebugAdaptersExecutablePlan), + "debug.transport.policy" => Some(Self::DebugTransportPolicy), + "debug.transport.handle" => Some(Self::DebugTransportHandle), + "debug.transport.release" => Some(Self::DebugTransportRelease), "plugins.declarative.checkRuntimeVersion" => { Some(Self::PluginsDeclarativeCheckRuntimeVersion) } @@ -816,6 +826,9 @@ mod tests { "debug.startDebuggingResponse", "debug.disconnect", "debug.destroySession", + "debug.transport.policy", + "debug.transport.handle", + "debug.transport.release", ] { assert!(CoreCommand::parse(command).is_some(), "missing {command}"); } diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index e74f189064..6688207a0c 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -219,6 +219,22 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugTransportPolicy => match crate::debug::transport_policy(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, + CoreCommand::DebugTransportHandle => { + match crate::debug::handle_transport_event(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugTransportRelease => { + match crate::debug::release_transport(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::DebugAdaptersStartArguments => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 3719056ad1..0383e71c16 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -26,6 +26,41 @@ fn debug_adapter_endpoint_validation_crosses_the_json_boundary() { } } +#[test] +fn debug_transport_commands_cross_the_json_boundary() { + let call = |command: &str, payload: Value| -> Value { + let request = + serde_json::json!({"id": "transport", "command": command, "payload": payload}); + serde_json::from_str(&execute_json(&request.to_string())) + .expect("transport response is JSON") + }; + let policy = call("debug.transport.policy", serde_json::json!({})); + assert_eq!(policy["ok"], true); + assert_eq!(policy["data"]["initializationMilliseconds"], 20_000); + + let id = "protocol-test-transport"; + let started = call( + "debug.transport.handle", + serde_json::json!({"transportId": id, "event": {"type": "start"}}), + ); + assert_eq!(started["ok"], true); + assert_eq!(started["data"]["phase"], "discovering"); + assert_eq!(started["data"]["effects"][1]["kind"], "discoverEndpoint"); + + let rejected = call( + "debug.transport.handle", + serde_json::json!({"transportId": id, "event": {"type": "start", "limits": {"writeMilliseconds": 0}}}), + ); + assert_eq!(rejected["ok"], false); + assert_eq!(rejected["error"]["code"], "invalid_request"); + + let released = call( + "debug.transport.release", + serde_json::json!({"transportId": id}), + ); + assert_eq!(released["data"]["released"], true); +} + #[test] fn ping_exposes_protocol_version() { let response: Value = serde_json::from_str(&execute_json( diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f2d956a480..95b4707ccc 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -978,6 +978,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `maven.testResults` | Parse bounded JUnit/Surefire result summaries and failure locations | | `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | | `debug.validateAdapterEndpoint` | Validate an IDE-to-DAP TCP endpoint and classify whether remote consent is required; performs no DNS or network I/O | +| `debug.transport.policy` | Return the default byte-stream deadlines, queue budgets and the session `initialize` deadline; creates no state | +| `debug.transport.handle` | Apply one platform event to an IDE-to-adapter byte-stream lifecycle and return the effects the platform runs next | +| `debug.transport.release` | Forget the lifecycle of a stopped byte stream once the platform dropped its handles | | `debug.launch` | Queue a launch or attach request, including during initialization | | `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | | `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | @@ -2110,13 +2113,94 @@ local fallback. This host-only field is not forwarded as DAP arguments: Core's launch configuration ignores it. `request` still independently chooses `launch` or `attach`. The provider's language-server port source remains loopback-only. Closing this connection never stops the external server, shared language server, -debuggee, or user-managed tunnel. On user stop, the macOS TCP host makes a +debuggee, or user-managed tunnel. On user stop, the TCP host makes a bounded best-effort drain of queued frames (including disconnect) for up to one second before closing; this is local write completion, not a remote acknowledgement. Failure and explicit transport abort close immediately. There is no automatic reconnect or request replay. Proxy/tunnel implementations and plugin `tcpPort` / `tcpAnnounced` process hosting are not enabled by this API. +The `debug.transport.*` commands hold the lifecycle rules of that byte stream +so every platform network layer shares one rule set. They are separate from the +DAP session state: Core never opens a socket, resolves DNS, reads a clock or +sees the bytes. The platform keeps the socket, the DNS and system timers, and +the queued frames; only byte counts cross the JSON boundary. + +`debug.transport.policy` ignores its payload and returns `{ limits, +initializationMilliseconds }`. `limits` holds `discoveryMilliseconds` (10000, +endpoint discovery such as a cold language-server start), `connectMilliseconds` +(10000), `writeMilliseconds` (10000, one stalled write, never the length of a +session), `bufferedBytes` (8 MiB), `bufferedWrites` (1024) and +`drainMilliseconds` (1000). `initializationMilliseconds` is the connect budget +plus 10000, the window a DAP `initialize` round trip gets, so a session never +gives up before its transport does. + +`debug.transport.handle` accepts exactly `{ transportId, event }` and returns a +step `{ generation, phase, outcome, effects }`. `transportId` is a host-chosen +non-empty identifier of at most 512 bytes. Only a `start` event creates a +transport. A callback for an unknown or released one is `ignored`, and `send` +to it is `rejectedNotRunning`. `event.type` is one of: + +- `start { limits? }` begins a lifecycle; `limits` overrides any of the six + defaults with an integer from 1 through 2147483647, otherwise + `invalid_request`. A repeat while running is `ignored`. A `start` while the + previous lifecycle is draining replaces it. +- `send { bytes }` queues one frame of that size. The host keeps the bytes in + first-in, first-out order. An empty frame is accepted and queued as nothing. +- `stop {}` is a user cancel: silent teardown, no termination. +- `finishSendingAndStop {}` flushes queued frames of a ready stream for at most + `drainMilliseconds`, then closes silently; with nothing queued or before the + stream is ready it closes at once. While draining, new `send` is + `rejectedNotRunning`, received data is `ignored` and any failure is silent + because the owner already released its session. +- `endpointResolved { generation, host, port }`, `connectionReady`, + `dataReceived`, `peerClosed`, `failed { message }`, `writeCompleted { + writeId }` and `timerFired { timerId }`, each carrying the `generation` the + host captured when it started the work being answered. `port` is a signed + integer so that an out-of-range provider value fails the transport + (`invalidEndpoint`) instead of the request. + +Every event field is checked strictly: an unknown event, an unknown field or a +missing field is `invalid_request`. + +`generation` rises whenever outstanding work is invalidated (a start, a finish, +a stop). A callback whose generation is not the live one, or that arrives in +the wrong phase, is `ignored`. Timer and write ids are unique per transport and +never reused, so a timer the host failed to cancel and a write completion from +a replaced write are `ignored` too. `phase` is `stopped`, `discovering`, +`connecting`, `ready` or `draining`. `outcome` is `applied`, `ignored`, +`rejectedNotRunning` or `rejectedBufferLimit`. The last one means the queue went +over its byte or count budget: Core has already failed the transport and the host +rejects that one `send`. + +`effects` is an ordered list the host runs top to bottom: + +- `discoverEndpoint` asks for the endpoint and reports `endpointResolved` or `failed`. +- `armTimer { timerId, purpose, afterMilliseconds }` and `cancelTimer { timerId }`. + `purpose` is `discovery`, `connect`, `write` or `drain`. The host reports + `timerFired` when an armed timer elapses. +- `connect { host, port }` opens the socket; `port` is already 1 through 65535. +- `write { writeId, bytes }` sends the oldest queued frame, which has that size, + and reports `writeCompleted` or `failed`. At most one write is in flight. +- `deliverData` forwards the received bytes to the owner. +- `close` cancels pending discovery, unhooks and stops the socket and drops + queued frames. +- `reportFailure { reason, message? }` shows the failure on the owner's error + output. `reason` is `startupTimedOut`, `writeTimedOut`, `bufferLimit`, + `invalidEndpoint` or `host`. The platform owns the localized text of the first + four; for `host` the message is the host's own upstream diagnostic, echoed + back unchanged. +- `notifyTermination { exitCode }` ends the owner's session: 0 for a clean peer + close, 1 after a failure. It comes last, after `reportFailure`. + +A host that re-enters Core while it runs a step's effects, for example an error +observer that restarts the transport, compares the live generation with the +step's and abandons the remainder of the older step. `debug.transport.release` +accepts `{ transportId }` and returns `{ released }`; hosts call it after `stop` +once they dropped the socket. Scripted scenarios, with the exact steps expected, +live in `shared/fixtures/debug/transport-lifecycle-v1.json`; platform adapter +tests can replay them. + `debug.createSession` accepts `{ sessionId, adapterId, rootPath, supportsRunInTerminalRequest, disabledCapabilities? }`. `disabledCapabilities` carries the plugin adapter's `capabilities.disabled` diff --git a/shared/fixtures/debug/transport-lifecycle-v1.json b/shared/fixtures/debug/transport-lifecycle-v1.json new file mode 100644 index 0000000000..78c8d3910e --- /dev/null +++ b/shared/fixtures/debug/transport-lifecycle-v1.json @@ -0,0 +1,3149 @@ +{ + "version": 1, + "description": "Scripted byte-stream lifecycle scenarios. Each step feeds one event to a fresh transport and expects the exact step Core answers with. Timer ids are allocated 1, 2, ... per transport and never reused; write ids likewise. Platform adapters reuse these scenarios to prove they execute the effects Core returns.", + "scenarios": [ + { + "name": "frames queued during discovery flush once connected, then a clean EOF terminates with 0", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 10 + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "dataReceived", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "deliverData" + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + }, + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 10 + } + ] + } + }, + { + "event": { + "type": "dataReceived", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "deliverData" + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 3 + } + ] + } + }, + { + "event": { + "type": "peerClosed", + "generation": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "close" + }, + { + "kind": "notifyTermination", + "exitCode": 0 + } + ] + } + }, + { + "event": { + "type": "dataReceived", + "generation": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "peerClosed", + "generation": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "discovery that never finds an endpoint times out once", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "startupTimedOut" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "a connect that never completes times out on its own budget, not the discovery remainder", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 1 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 2 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "startupTimedOut" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "a connect failure reports the host message and terminates with 1", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "failed", + "generation": 1, + "message": "connection refused" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "host", + "message": "connection refused" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + }, + { + "event": { + "type": "failed", + "generation": 1, + "message": "late" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "a port above 65535 from the provider fails before any connect", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 70000 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "invalidEndpoint" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "port zero from the provider fails before any connect", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 0 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "invalidEndpoint" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "a negative port from the provider fails before any connect", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": -1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "invalidEndpoint" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "an empty host from the provider fails before any connect", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "", + "port": 5005 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "invalidEndpoint" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "an endpoint reported twice or for a stale generation is ignored", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 9, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 9 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "a cancelled startup timer that fires anyway cannot fail a ready stream", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 2 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "a stalled write times out and terminates with 1", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 10 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 10 + } + ] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 3 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "writeTimedOut" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "custom limits shape the armed deadlines", + "steps": [ + { + "event": { + "type": "start", + "limits": { + "discoveryMilliseconds": 120000, + "connectMilliseconds": 500, + "writeMilliseconds": 250 + } + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 120000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 500 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 250 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 1 + } + ] + } + } + ] + }, + { + "name": "only one write is in flight and frames go out in order", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 5 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 5 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 7 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 0 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 2 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 3 + }, + { + "kind": "armTimer", + "timerId": 4, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 2, + "bytes": 7 + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 2 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 4 + } + ] + } + } + ] + }, + { + "name": "exceeding the queued frame count fails the stream and refuses further sends", + "steps": [ + { + "event": { + "type": "start", + "limits": { + "bufferedWrites": 2 + } + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "rejectedBufferLimit", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "bufferLimit" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "rejectedNotRunning", + "effects": [] + } + } + ] + }, + { + "name": "exceeding the queued byte budget fails the stream, but filling it exactly is allowed", + "steps": [ + { + "event": { + "type": "start", + "limits": { + "bufferedBytes": 100 + } + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 60 + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 40 + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "rejectedBufferLimit", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "bufferLimit" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "completed writes free their share of the byte budget", + "steps": [ + { + "event": { + "type": "start", + "limits": { + "bufferedBytes": 100 + } + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 100 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 100 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "rejectedBufferLimit", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 3 + }, + { + "kind": "close" + }, + { + "kind": "reportFailure", + "reason": "bufferLimit" + }, + { + "kind": "notifyTermination", + "exitCode": 1 + } + ] + } + } + ] + }, + { + "name": "a completed write makes room for the next frame", + "steps": [ + { + "event": { + "type": "start", + "limits": { + "bufferedBytes": 100 + } + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 100 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 100 + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 3 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 100 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 4, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 2, + "bytes": 100 + } + ] + } + } + ] + }, + { + "name": "a user stop is silent and invalidates every outstanding callback", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "stop" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + }, + { + "kind": "close" + } + ] + } + }, + { + "event": { + "type": "stop" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "rejectedNotRunning", + "effects": [] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 2 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "failed", + "generation": 1, + "message": "late" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "a stopped transport can start again and ignores the previous run's callbacks", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "stop" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "close" + } + ] + } + }, + { + "event": { + "type": "start" + }, + "expect": { + "generation": 3, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 2, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 3, + "phase": "discovering", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 1 + }, + "expect": { + "generation": 3, + "phase": "discovering", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 3, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 3, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + }, + { + "kind": "armTimer", + "timerId": 3, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + } + ] + }, + { + "name": "starting twice keeps the running lifecycle", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "finishing a stream with queued frames drains them silently once the last write completes", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 4 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 4 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 6 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 4, + "purpose": "drain", + "afterMilliseconds": 1000 + } + ] + } + }, + { + "event": { + "type": "dataReceived", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "rejectedNotRunning", + "effects": [] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 3 + }, + { + "kind": "armTimer", + "timerId": 5, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 2, + "bytes": 6 + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 2 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 5 + }, + { + "kind": "cancelTimer", + "timerId": 4 + }, + { + "kind": "close" + } + ] + } + } + ] + }, + { + "name": "the drain deadline closes a stream whose peer stopped reading, without a termination", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 4 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 4 + } + ] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 4, + "purpose": "drain", + "afterMilliseconds": 1000 + } + ] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 4 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 3 + }, + { + "kind": "close" + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "a failure while draining stays silent", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 4 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 4 + } + ] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 4, + "purpose": "drain", + "afterMilliseconds": 1000 + } + ] + } + }, + { + "event": { + "type": "failed", + "generation": 1, + "message": "reset" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 4 + }, + { + "kind": "cancelTimer", + "timerId": 3 + }, + { + "kind": "close" + } + ] + } + } + ] + }, + { + "name": "a write timeout while draining stays silent", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 4 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 4 + } + ] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 4, + "purpose": "drain", + "afterMilliseconds": 1000 + } + ] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 3 + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 4 + }, + { + "kind": "close" + } + ] + } + } + ] + }, + { + "name": "finishing with nothing queued, or before the stream is ready, closes immediately", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 3 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + }, + { + "kind": "close" + } + ] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "finishing a ready stream with an empty queue closes immediately", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 2, + "phase": "stopped", + "outcome": "applied", + "effects": [ + { + "kind": "close" + } + ] + } + } + ] + }, + { + "name": "starting while a drain is pending replaces it and ignores the old drain's callbacks", + "steps": [ + { + "event": { + "type": "start" + }, + "expect": { + "generation": 1, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 1, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "endpointResolved", + "generation": 1, + "host": "127.0.0.1", + "port": 5005 + }, + "expect": { + "generation": 1, + "phase": "connecting", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 1 + }, + { + "kind": "armTimer", + "timerId": 2, + "purpose": "connect", + "afterMilliseconds": 10000 + }, + { + "kind": "connect", + "host": "127.0.0.1", + "port": 5005 + } + ] + } + }, + { + "event": { + "type": "connectionReady", + "generation": 1 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 2 + } + ] + } + }, + { + "event": { + "type": "send", + "bytes": 4 + }, + "expect": { + "generation": 1, + "phase": "ready", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 3, + "purpose": "write", + "afterMilliseconds": 10000 + }, + { + "kind": "write", + "writeId": 1, + "bytes": 4 + } + ] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 1, + "phase": "draining", + "outcome": "applied", + "effects": [ + { + "kind": "armTimer", + "timerId": 4, + "purpose": "drain", + "afterMilliseconds": 1000 + } + ] + } + }, + { + "event": { + "type": "start" + }, + "expect": { + "generation": 3, + "phase": "discovering", + "outcome": "applied", + "effects": [ + { + "kind": "cancelTimer", + "timerId": 4 + }, + { + "kind": "cancelTimer", + "timerId": 3 + }, + { + "kind": "close" + }, + { + "kind": "armTimer", + "timerId": 5, + "purpose": "discovery", + "afterMilliseconds": 10000 + }, + { + "kind": "discoverEndpoint" + } + ] + } + }, + { + "event": { + "type": "writeCompleted", + "generation": 1, + "writeId": 1 + }, + "expect": { + "generation": 3, + "phase": "discovering", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "timerFired", + "generation": 1, + "timerId": 4 + }, + "expect": { + "generation": 3, + "phase": "discovering", + "outcome": "ignored", + "effects": [] + } + } + ] + }, + { + "name": "events for a stream that never started are ignored or refused", + "steps": [ + { + "event": { + "type": "send", + "bytes": 1 + }, + "expect": { + "generation": 0, + "phase": "stopped", + "outcome": "rejectedNotRunning", + "effects": [] + } + }, + { + "event": { + "type": "dataReceived", + "generation": 0 + }, + "expect": { + "generation": 0, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "peerClosed", + "generation": 0 + }, + "expect": { + "generation": 0, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "stop" + }, + "expect": { + "generation": 0, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + }, + { + "event": { + "type": "finishSendingAndStop" + }, + "expect": { + "generation": 0, + "phase": "stopped", + "outcome": "ignored", + "effects": [] + } + } + ] + } + ] +} diff --git a/shared/fixtures/debug/v1-baseline.json b/shared/fixtures/debug/v1-baseline.json index 1fe2f48430..e706675c2d 100644 --- a/shared/fixtures/debug/v1-baseline.json +++ b/shared/fixtures/debug/v1-baseline.json @@ -480,6 +480,30 @@ } ], "gaps": [] + }, + { + "id": "adapter-tcp-lifecycle", + "behavior": "The TCP byte-stream lifecycle (state transitions, generation tokens, finish-once, connect/write/drain deadlines, queue budgets, single in-flight write) is decided by Rust Core and replayed from a shared fixture; platforms only run the returned effects", + "status": "covered", + "evidence": [ + { + "platform": "rust", + "path": "rust/lithe-core/src/debug/transport_lifecycle.rs", + "test": "lifecycle_fixture_scenarios_match_expected_steps", + "fixture": "shared/fixtures/debug/transport-lifecycle-v1.json" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheTests/TCPDebugAdapterTests.swift", + "test": "terminalEventsCleanUpExactlyOnceAndRevokeCallbacks" + }, + { + "platform": "macos", + "path": "macos/Tests/LitheTests/TCPDebugAdapterTests.swift", + "test": "restartRejectsOldSocketCallbacksAndWriteCompletions" + } + ], + "gaps": [] } ] } diff --git a/shared/platform-feature-matrix/features/debug-tcp-transport.json b/shared/platform-feature-matrix/features/debug-tcp-transport.json index 1c1acfb42d..1c9ff194c2 100644 --- a/shared/platform-feature-matrix/features/debug-tcp-transport.json +++ b/shared/platform-feature-matrix/features/debug-tcp-transport.json @@ -6,6 +6,7 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Platform/MacOS/Debug/MacTCPDebugAdapterTransport.swift", + "macos/Sources/Lithe/Core/Rust/RustDebugTransportCore.swift", "macos/Sources/Lithe/Platform/MacOS/Debug/MacExternalDebugAdapterTransport.swift", "macos/Sources/Lithe/Platform/MacOS/Debug/MacHostedPortDebugAdapterTransport.swift", "macos/Sources/Lithe/Views/Debug/ExternalDebugAdapterView.swift", @@ -22,12 +23,14 @@ "windows": { "evidence": [ "rust/lithe-core/src/debug/endpoint.rs", - "shared/fixtures/debug/adapter-endpoints-v1.json" + "rust/lithe-core/src/debug/transport_lifecycle.rs", + "shared/fixtures/debug/adapter-endpoints-v1.json", + "shared/fixtures/debug/transport-lifecycle-v1.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Debug", "verification": "macOS:在有已安装调试提供方的源码文件上,从 Debug 更多操作打开连接调试适配器,填写已运行的 DAP 服务地址、launch/attach 与适配器参数。分别验证回环、IPv4/IPv6/DNS 外部地址、非回环默认取消的逐次确认、错误 JSON 与非法端点。确认 IDE 到 DAP 的地址不覆盖适配器到 debuggee 的地址,既有 stdio 与 tcpLanguageServer 回环限制不变。验证端点发现与建连两段独立超时(冷启动发现不缩短也不拉长建连)、被拒绝/不可达端口立即以系统原因结束、连接失败/未获批准/已有活动会话时对话框保持打开并保留已填内容、当前文件无调试提供方时菜单项不可用、单个在途写入、字节/消息预算、写入超时、EOF、解析/建连/已连接时取消和旧回调隔离;停止时断开帧最多排空 1 秒且 attach 的 terminateDebuggee 为 false,超时仍回收;停止后外部服务、共享 LSP、attach 目标与用户隧道仍存活。真实回环测试覆盖 Core initialize/attach 帧;真实 JVM/debugpy/GDB 和界面全链路仍待验收。Windows 未接入此 TCP 宿主。", - "notes": "复用 Apple Network 与 Rust DAP 引擎;用户端点独立于 attach 目标。入口复用当前文件的提供方索引,尚不发现仅附加或非文件型插件。仅直连,有可替换的连接工厂但没有代理、SSH、转发、凭据、自动重连、持久化配置或 tcpPort/tcpAnnounced 托管进程支持。运行时不写发行资源。" + "notes": "复用 Apple Network 与 Rust DAP 引擎;连接生命周期(状态、代次、期限、队列预算、单个在途写入)由 Rust Core 的独立状态机持有,macOS 只做 NWConnection、解析与系统计时器并执行 Core 返回的效果,Windows 接入时复用同一状态机与 fixture 但尚无宿主 TCP 实现;用户端点独立于 attach 目标。入口复用当前文件的提供方索引,尚不发现仅附加或非文件型插件。仅直连,有可替换的连接工厂但没有代理、SSH、转发、凭据、自动重连、持久化配置或 tcpPort/tcpAnnounced 托管进程支持。运行时不写发行资源。" }