From 98675196dc3318c932d770d544d8a378334d86ac Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 26 Oct 2026 15:37:17 +0800 Subject: [PATCH 1/5] fix(remote): adapt workspace I/O and plugin execution for #1507 --- .../2026-10-24-remote-workspace-routing.md | 34 +- docs/ci-builds.md | 24 + macos/Resources/en.lproj/Localizable.strings | 36 + .../zh-Hans.lproj/Localizable.strings | 36 + .../Core/Ports/RemoteConnectionProbing.swift | 149 +++- .../Lithe/Core/Ports/RuntimeLocator.swift | 28 + .../Core/Rust/RustBuildSystemOperations.swift | 2 + .../Rust/RustCoreBridge+RemoteWorkspace.swift | 63 ++ .../Lithe/Core/Rust/RustCoreBridge.swift | 38 +- .../RustLanguageServerRuntimeAdapter.swift | 4 + ...stRemoteConnectionOperations+Plugins.swift | 37 + ...stRemoteConnectionOperations+Process.swift | 39 + ...stRemoteConnectionOperations+Runtime.swift | 150 ++++ .../Rust/RustRemoteConnectionOperations.swift | 136 ++- .../Models/AppModel/AppModel+Terminal.swift | 18 +- .../Models/Runtime/ProjectRuntimeModels.swift | 7 +- .../Build/MacBuildProviderSessions.swift | 88 +- .../MacOS/Build/MacBuildToolResolver.swift | 134 ++- .../Platform/MacOS/MacServiceContainer.swift | 109 ++- ...acPluginLanguageServerLaunchResolver.swift | 8 + .../MacOS/Plugins/MacPluginPackageStore.swift | 15 +- .../Remote/MacRemoteBuildCommandRunner.swift | 85 ++ .../Remote/MacRemoteBuildStateLocator.swift | 68 ++ .../MacRemotePluginLaunchPreparer.swift | 198 +++++ .../MacRemotePluginPackageDeployer.swift | 246 ++++++ .../Remote/MacRemoteRuntimeLocator.swift | 239 +++++ .../Remote/MacRemoteShellDiscovery.swift | 82 ++ .../Remote/MacRemoteTerminalTransport.swift | 312 +++++++ .../MacOS/Runtime/MacRuntimeDiscovery.swift | 5 + .../Terminal/MacTerminalShellDiscovery.swift | 5 +- .../MacOS/Terminal/MacTerminalTransport.swift | 3 +- .../MacOS/Vcs/MacVcsProviderSessions.swift | 47 +- .../Services/Java/ProjectRuntimeService.swift | 73 +- .../Remote/RemoteDirectoryObservation.swift | 133 +++ .../Remote/RemoteWorkspaceRouting.swift | 123 ++- .../Lithe/Views/Terminal/TerminalView.swift | 6 +- .../Execution/BuildSystemContracts.swift | 40 +- .../LanguageServerRuntimeContracts.swift | 5 + .../Language/LanguageToolingContracts.swift | 2 + .../Services/BuildSystemService.swift | 71 +- .../Runtime/LanguageProviderRuntime.swift | 39 + .../Runtime/LanguageServerSession.swift | 297 +++++-- .../LanguageToolingSessionManager.swift | 2 +- .../Application/TerminalFeatureModel.swift | 99 ++- .../Module/TerminalModule.swift | 16 +- .../Ports/TerminalTransport.swift | 13 + .../Runtime/TerminalSession.swift | 88 +- .../TerminalModuleTests.swift | 42 + .../MacBuildProviderSessionsTests.swift | 204 ++++- .../RemoteBuildExecutionTests.swift | 156 ++++ .../LitheTests/RemoteConnectionsTests.swift | 82 +- .../RemoteDirectoryObservationTests.swift | 113 +++ .../RemotePluginDeploymentTests.swift | 166 ++++ .../RemoteRuntimeDiscoveryTests.swift | 161 ++++ .../RemoteShellDiscoveryTests.swift | 80 ++ .../RemoteTerminalTransportTests.swift | 202 +++++ rust/Cargo.lock | 104 ++- rust/lithe-core/Cargo.toml | 3 + rust/lithe-core/src/build/commands.rs | 3 + rust/lithe-core/src/build/decode.rs | 31 +- .../src/build/integrations/maven.rs | 52 +- rust/lithe-core/src/build/invocation.rs | 10 +- rust/lithe-core/src/build/language_import.rs | 6 +- rust/lithe-core/src/build/model.rs | 4 +- rust/lithe-core/src/build/outputs.rs | 10 +- .../lithe-core/src/execution/configuration.rs | 23 +- .../src/execution/detectors/scan.rs | 18 +- .../src/execution/java_selection.rs | 16 +- rust/lithe-core/src/git/configuration.rs | 21 +- rust/lithe-core/src/git/execution_events.rs | 19 +- rust/lithe-core/src/git/history.rs | 44 +- rust/lithe-core/src/git/mod.rs | 136 ++- rust/lithe-core/src/git/patch_exchange.rs | 20 +- rust/lithe-core/src/git/rebase_session.rs | 15 +- rust/lithe-core/src/git/remote_history.rs | 196 +++++ rust/lithe-core/src/git/remote_process.rs | 330 +++++++ rust/lithe-core/src/git/rewrite.rs | 6 +- rust/lithe-core/src/lsp/interface/engine.rs | 68 +- rust/lithe-core/src/lsp/interface/mod.rs | 3 +- rust/lithe-core/src/lsp/interface/process.rs | 8 +- .../src/lsp/interface/remote_process.rs | 666 ++++++++++++++ rust/lithe-core/src/project/files.rs | 14 +- rust/lithe-core/src/project/maven.rs | 46 +- .../src/project/maven_dependency_tree.rs | 2 +- .../src/project/maven_test_reports.rs | 18 +- rust/lithe-core/src/project/search_index.rs | 27 +- rust/lithe-core/src/protocol/command.rs | 24 + rust/lithe-core/src/remote/mod.rs | 4 + rust/lithe-core/src/remote/sessions.rs | 54 +- .../src/remote/sessions_integration_tests.rs | 816 ++++++++++++++++++ rust/lithe-core/src/remote/upload.rs | 142 +++ rust/lithe-core/src/remote/workspace.rs | 170 ++++ rust/lithe-core/src/remote/workspace_host.rs | 572 ++++++++++++ .../src/remote/workspace_host/tests.rs | 314 +++++++ rust/lithe-core/src/remote/workspace_io.rs | 624 ++++++++++++++ rust/lithe-core/src/runtime/dispatcher.rs | 69 +- .../lithe-core/src/tests/run_configuration.rs | 27 + rust/lithe-remote-client/src/api.rs | 1 + rust/lithe-remote-client/src/session.rs | 285 +++++- rust/lithe-remote-protocol/src/methods.rs | 55 ++ rust/lithe-remote-server/Cargo.toml | 1 + rust/lithe-remote-server/src/connection.rs | 7 + rust/lithe-remote-server/src/daemon.rs | 36 +- rust/lithe-remote-server/src/fs.rs | 118 ++- rust/lithe-remote-server/src/lib.rs | 1 + rust/lithe-remote-server/src/platform/mod.rs | 1 + rust/lithe-remote-server/src/session.rs | 5 + rust/lithe-remote-server/src/upload.rs | 203 +++++ rust/lithe-remote-server/tests/server.rs | 216 +++++ scripts/reuse-worktree-resources.mjs | 5 + scripts/test-reuse-worktree-resources.mjs | 23 + scripts/worktree-resources.json | 25 +- .../contracts/remote-workspace-protocol-v1.md | 29 +- shared/contracts/rust-core-api.md | 34 + shared/i18n/locales/en-US.json | 20 +- shared/i18n/locales/zh-CN.json | 20 +- shared/i18n/messages.json | 205 +++++ .../remote-development-workspace.json | 17 +- .../features/remote-workspace.json | 17 +- 119 files changed, 10171 insertions(+), 546 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift create mode 100644 macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift create mode 100644 macos/Tests/LitheTests/RemoteBuildExecutionTests.swift create mode 100644 macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift create mode 100644 macos/Tests/LitheTests/RemotePluginDeploymentTests.swift create mode 100644 macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift create mode 100644 macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift create mode 100644 macos/Tests/LitheTests/RemoteTerminalTransportTests.swift create mode 100644 rust/lithe-core/src/git/remote_history.rs create mode 100644 rust/lithe-core/src/git/remote_process.rs create mode 100644 rust/lithe-core/src/lsp/interface/remote_process.rs create mode 100644 rust/lithe-core/src/remote/sessions_integration_tests.rs create mode 100644 rust/lithe-core/src/remote/upload.rs create mode 100644 rust/lithe-core/src/remote/workspace.rs create mode 100644 rust/lithe-core/src/remote/workspace_host.rs create mode 100644 rust/lithe-core/src/remote/workspace_host/tests.rs create mode 100644 rust/lithe-core/src/remote/workspace_io.rs create mode 100644 rust/lithe-remote-server/src/upload.rs diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md index 1f8dd8d73e..ddc2ec4af0 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -4,7 +4,7 @@ ## 先说结论 -远程文件夹以“本地占位根目录”的身份打开成工作区:根目录是 `Application Support/Lithe/RemoteWorkspaces/` 下的一个空目录,工作台照常拿到一个文件 URL;这个根目录下面的所有文件都在远端。文件树和编辑器经过的两个端口 `WorkspaceOperations` 和 `WorkspaceFileOperations` 外面各包了一层路由:URL 落在远程根目录下就交给远端,其余原样交给本地实现。第一版只让文件树、打开、编辑和保存走远端;Git、搜索、构建、语言服务和终端在远程工作区里看到的仍是本地空目录。 +远程文件夹以“本地占位根目录”的身份打开成工作区:根目录是 `Application Support/Lithe/RemoteWorkspaces/` 下的一个空目录,工作台照常拿到一个文件 URL;这个根目录下面的文件在远端。文件树与编辑器通过文件端口访问主机,Git、搜索和构建保留现有 Core 引擎、替换底层读写,终端把远端伪终端(PTY,负责交互进程与窗口尺寸)的字节交给本机渲染。JDK、Maven 和 Shell 的发现读取对应主机环境;通用插件部署与 stdio 已接入,远程 Java/SVN、Agent 执行位置选择与完整 SSH 工作流仍待完成和验收。 ## 问题 @@ -16,16 +16,28 @@ - **注册表**:`RemoteWorkspaceRegistry` 记录每个占位根对应的连接、远程路径和 Core 会话 id。打开远程项目时登记,同一个根再次打开时,旧会话会被替换并断开。 - **路由**:组合根(`MacServiceContainer`,负责组装依赖的地方)把本地实现包进 `RemoteRoutingWorkspaceOperations` 和 `RemoteRoutingFileOperations`。路由层对端口里的**每个**方法都显式转发:如果只覆盖一部分,没覆盖的方法会悄悄走协议扩展里的默认实现,本地文件的行为就变了。 - **文档版本**:和本地一样,文档标识是读到的原始字节的 SHA-256。远端用大小和修改时间做冲突检测,所以路由层为每个读到的标识记住远端版本(最多 512 个),保存时把它作为 `expected` 传给 `remote.writeFile`;不认识的标识会先读当前内容比对哈希。文件变了就返回冲突,不覆盖。 +- **文件树变更**:新文件只创建,不覆盖已存在文件;重命名、目录复制和删除都使用同一会话的主机文件协议,跨本机与远端或不同会话的移动和复制明确失败。复制先在目标父目录的私有暂存目录完成,再原子发布且拒绝覆盖,避免并发创建目标时丢失内容。界面的“移到废纸篓”复用 `trash` 上游库,在 Linux 使用 Freedesktop Trash、在远程 Mac 使用系统 Trash;不可用时失败,不改成永久删除。需要按内容标识保护的 `trashDocument` 仍待单独适配,不能假装普通 Trash 操作具有原子身份保护。 +- **外部修改监听**:`RemoteDirectoryObservation` 在工作线程订阅主机原生 watcher,转换工作区内路径后交给已有文件树、Git、搜索索引和文档刷新流程。Git common directory 位于工作区外时保留主机路径并额外监听。订阅先于创建,启动和溢出要求重新扫描,根目录移除结束订阅;取消先屏蔽旧 generation,再通过 Core 的资源所有者停止主机 watcher。打开的文档共用其工作区根的订阅,列表缓存随文档变更清空。 - **最近项目**:占位根目录不进“最近项目”,因为它离开连接就只是个空目录;重新打开要走“远程开发”窗口。 +- **Core 请求的主机身份**:桥接层只读取请求结构里明确的工作区根字段,附上注册表中的会话与两侧根目录;同一次 Git 操作涉及不同主机或本机与远端混合时拒绝执行。Core 在请求范围内持有这份身份,长期历史游标继续持有原连接,不依赖“当前选中主机”的全局变量。远端出错时返回失败,不访问本机占位目录或本机 Git。 +- **Git 与搜索继续复用现有引擎**:Git 参数、状态解析、提交、历史和搜索索引仍由原 Core 模块负责。文件与进程适配器只把请求送到项目所在主机;Git 可执行文件与版本按连接缓存,设置来源由同一主机 Git 返回。创建或管理 worktree、克隆时,仅映射类型化请求中明确的目标目录字段;原始 Git 参数、仓库地址和用户文本保持原样。搜索缓存增加连接身份,避免同一个占位 URL 曾经生成的本机空索引污染远端结果。远端服务不引入 Core、JDK、LSP 或 Agent Host;Linux musl 二进制各保持 1.5 MiB 上限与 panic unwind(线程出错时保留其他会话)。 +- **远端文件读写限制**:Core 一次装入内存的文件最多 32 MiB,分块读取还会核对版本、偏移与结束标记;流式快照哈希可读取更大的文件。原子写入沿用协议的 8 MiB 上限,超限在编码和发送前失败,不拆成会暴露半份内容的覆盖操作。文件或目录是否存在的查询必须保留权限、断线等失败,不能把它们当成“不存在”。临时目录清理失败记录错误类别,避免静默残留或把用户路径写入日志。 +- **终端确认与清理**:交互终端默认使用远端账户的 Shell,显式菜单选择保留独立 argv;调试进程按工作区身份选择主机,即使调试器给出的工作目录已经是远端绝对路径。启动在收到远端确认后才发布就绪与 PID,等待中的启动也算模块活动资源。重启先等待上次传输清理,输入按顺序发送,待发送尺寸只保留最新值;写入与缩放复用 Core 的操作取消,退出释放远程渲染标记,不把远端 PID 交给本机进程监控。 +- **运行时与 Shell 发现**:网络调用在平台工作线程运行,界面读取缓存;发现有数量、数据和时间限制,取消或切换工作区后丢弃旧结果,失败不发布部分结果或本机回退。Shell 从远端登录环境、`/etc/shells` 和文件权限发现,旧服务缺目标权限时再查询链接真实路径;本机终端设置里的可执行文件路径不作为远程默认值。 +- **资源生命周期**:Git 临时索引和源文件快照在远端临时目录或 Git common directory,由单次操作清理;探测、可执行文件与搜索缓存保存在客户端内存并按连接隔离。服务的 socket、锁和日志身份同时包含会话键与二进制 SHA-256,版本号相同但内容变化时不复用旧常驻进程。以上资源在 `scripts/worktree-resources.json` 中明确排除跨工作树复制,运行时不写安装资源,不改变签名和 Sparkle delta 基线。 +- **构建与插件运行**:构建扫描、工具探测、构建进程和报告读取使用原工作区的主机身份,stdout/stderr 分别增量解码。插件只部署经过原安装来源复核的包,异平台变体必须同版本、同声明且不增加批准程序;复用市场校验和下载器,不向小型服务引入包管理或语言引擎。目标文件通过带 SHA-256 的分块上传原子发布,普通文件只读、可执行文件只读可执行,可写状态位于远端用户目录。快照清理捕获原连接,在 Core 销毁进程后执行;完整快照的断线与崩溃回收仍待补齐,不能把上传暂存清理当成完整快照清理。 +- **stdio 与启动**:语言、构建和 VCS 提供者复用 Core 的协议引擎,远程通道保留有界输入输出与原连接。异步部署、工具探测和启动在工作线程进行,启动期间保留有界文档快照与文件变更,停止后到达的会话先销毁再删除包。仅映射协议定义的 URI 和 VCS 路径字段,参数、文本与插件自定义值保持原样。依赖本机原生集成的 Java 仍明确拒绝,不能伪装成通用 stdio 已支持。 ### 正确做法 新增一种需要读写工作区文件的能力时,通过这两个端口访问文件,远程工作区就会自动可用。 +例如搜索使用已有索引语义,通过主机文件适配器读取内容;终端接收 `started` 确认后再把远端 PID 返回给调试器。新增功能仍需逐个检查调用链,文件端口本身不能让本机进程自动变成远端进程。 ### 不要这样做 - 不要在功能代码里用 `FileManager` 直接读工作区下的文件:在远程工作区里它只会看到空目录。 - 不要从占位根目录推导“这是远程项目”再写特殊分支,应该询问 `RemoteWorkspaceRegistry`。 +- 不要把本机签名插件包内的 macOS 可执行文件路径直接传给 Linux;包的目标平台、资源部署和可写状态目录尚未适配时,必须保留失败状态,不宣称插件可用。 ## 考虑过的备选方案 @@ -40,13 +52,25 @@ ## 后果 - 远程工作区第一版:文件树按需列目录、打开和保存 UTF-8 文件(含 BOM)、保存时检测冲突。 -- 暂不支持:新建、删除、重命名、复制(报“不支持”);外部修改监听;Git、搜索、构建、语言服务和远端终端。这些要逐个接到 `lithe-remote-client` 已有的远端能力上。 +- 新增适配覆盖:远端 PTY、按主机探测环境、Git 文件与进程 I/O、搜索索引与 Shift+Shift 的文件及内容查询。真实服务 fixture 已覆盖状态、暂存、分页历史、设置来源、worktree 创建和管理、克隆、保留无关暂存的选择文件提交,以及未保存缓冲区的行历史;不能据此把完整真实 SSH 产品流程标为已验收。 +- 文件树新建、复制、重命名、移到主机废纸篓和普通删除已接入;外部修改监听覆盖文件树与文档,真实 SSH 的原生 watcher 仍需产品验收。 +- 构建 I/O、工具与状态目录适配,以及通用插件部署和 stdio 提供者接入已有实现与 fixture;真实 SSH 的完整构建、插件启动与取消链路仍待验收。 +- 仍待完成:远程 Java 原生集成、SVN 的兼容目标包与完整 VCS 流程、Agent 执行位置选择、完整快照断线/崩溃回收,以及 stdio 断线、顺序、背压与取消的真实主机覆盖。当前分支按 Draft PR 分阶段评审,不关闭 #1507。 - 连接在工作区关闭后仍保留,直到 App 退出。 +- 代价:主机调用增加往返时间,远端无有效工具或连接中断时必须明确失败;缓存与长期资源都需要保留原连接身份,不能复用本机路径命中的结果。 +- 当前限制:Git 原子写入仍保留 8 MiB 上限;新分块上传仅用于已批准插件文件,不代表 Git 大文件临时内容已经接入。 ## 验证 ```bash ./scripts/test-macos.sh --filter RemoteConnections +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemoteWorkspaceRoutingTests|RemoteDirectoryObservationTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemoteRuntimeDiscoveryTests|RemoteShellDiscoveryTests|RemoteTerminalTransportTests|TerminalModuleTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemotePluginDeploymentTests|RemoteBuildExecutionTests|MacBuildProviderSessionsTests|MacVcsProviderSessionsTests' +./scripts/verify-remote-workspace.sh +node scripts/test-reuse-worktree-resources.mjs +./scripts/verify-runtime-bundle-immutability.sh +./scripts/verify-platform-feature-matrix.sh ./scripts/verify-service-boundaries.sh ``` @@ -56,3 +80,9 @@ - `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteWorkspaceRoots.swift` - `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` - `macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift` +- `rust/lithe-core/src/remote/workspace.rs` +- `rust/lithe-core/src/remote/workspace_host.rs` +- `rust/lithe-core/src/remote/workspace_io.rs` diff --git a/docs/ci-builds.md b/docs/ci-builds.md index dbc151d0a4..60b3b168f8 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -395,6 +395,30 @@ HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平 `remote` 中记住的密码或口令、`Application Support/Lithe/RemoteWorkspaces/` 下打开远程文件夹时 创建的空占位根目录,以及远端主机上 `~/.lithe-server/bin/` 中按 SHA-256 校验后复用的服务。 它们属于用户资料或远端主机,没有构建身份,所有复用阶段均拒绝复制。 +远端常驻服务的 socket、锁和日志使用“会话键 + 完整二进制 SHA-256”的散列作为身份; +即使版本号不变,重新构建后也不会连回旧二进制启动的服务。这些路径仍属于远端用户状态, +不复制进工作树或应用安装目录。 + +远程 Git 的临时索引、行历史快照,以及连接内存中的运行时、Shell、Git 可执行文件和搜索索引 +登记为 `remote-workspace-session-state`。临时文件通过主机的临时目录或 Git common directory +创建,由单次操作清理;本地占位根目录不保存远端文件。探测与索引快照按连接隔离,显式刷新或 +替换连接后重建。它们没有可校验的构建身份,所有复用阶段在调用校验器前拒绝复制。 +运行时写入仅发生在远端可写目录或进程内存中,不影响签名、安装资源或 Sparkle delta。 +文件树复制还会在远端目标父目录创建 `.lithe-copy--/` 私有暂存目录, +操作完成后清理;它与文件监听的连接资源同属 `remote-workspace-session-state`,没有复用阶段。 +移到废纸篓的内容和恢复元数据属于主机账户(Linux 的 XDG 数据目录/卷废纸篓,macOS 的系统 Trash), +由系统和用户保管,不是缓存或构建输入,也不复制到本机工作树。 + +远端构建输出、插件运行快照和分块上传同样登记在 `remote-workspace-session-state`, +没有跨工作树复制阶段。上传在目标父目录用 `.lithe-upload--` 暂存, +按连接持有,失败、断线或五分钟期限后删除;SHA-256 与架构校验只授权本次运行。 +完整插件快照位于远端 `~/.lithe-server/plugin-runs//package`,进程销毁后释放; +客户端崩溃或断线后的完整快照回收仍为 TODO。插件配置、索引等持久状态位于远端 +`~/.lithe-server/state/plugins////workspaces/`, +由该工作区和提供者持有,构建临时输出按运行清理。异平台包下载复用市场下载器, +在平台临时目录解压,传输完成或失败后删除;校验来源包括市场归档 SHA-256、安装来源、 +已批准命令、声明逐字节一致性和签名。上述内容没有版本/平台/架构/工具链构建 stamp, +复用脚本在校验和复制前拒绝这些路径;运行时不改写本机或远端安装资源,不影响签名或 Sparkle delta。 MCP 编辑器、工具面板和指令页的原生验收截图分别输出到 `.artifacts/test-stability/mcp-ui/` 与 `.artifacts/test-stability/instructions-ui/`,随 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 687d3a7ad5..7e08c6df84 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -136,6 +136,8 @@ "8 spaces" = "8 spaces"; "; " = "; "; "A ChangeList with this name already exists." = "A ChangeList with this name already exists."; +"A Git operation cannot span different workspace hosts." = "A Git operation cannot span different workspace hosts."; +"A Git operation cannot span local and remote workspaces." = "A Git operation cannot span local and remote workspaces."; "A Hook with this name already exists." = "A Hook with this name already exists."; "A Java cache-retention target is not a removable directory." = "A Java cache-retention target is not a removable directory."; "A Java project with no build tool, Maven, or Gradle." = "A Java project with no build tool, Maven, or Gradle."; @@ -532,6 +534,7 @@ "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)." = "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)."; "Check the MCP command, URL and headers." = "Check the MCP command, URL and headers."; "Check the configuration format, API URL and model. Each configuration must be under 64 KiB." = "Check the configuration format, API URL and model. Each configuration must be under 64 KiB."; +"Check the workspace connection and refresh the available runtimes." = "Check the workspace connection and refresh the available runtimes."; "Check your internet connection and try again later." = "Check your internet connection and try again later."; "Check: %@" = "Check: %@"; "Checked %@" = "Checked %@"; @@ -915,6 +918,8 @@ "Could not connect to the Debug Adapter: %@" = "Could not connect to the Debug Adapter: %@"; "Could not connect to the Java Debug Server: %@" = "Could not connect to the Java Debug Server: %@"; "Could not copy your Stable settings" = "Could not copy your Stable settings"; +"Could not discover runtimes on the remote host. Check the connection and refresh." = "Could not discover runtimes on the remote host. Check the connection and refresh."; +"Could not discover shells on the remote host. Check the connection and try again." = "Could not discover shells on the remote host. Check the connection and try again."; "Could not import MCP JSON. Check the mcpServers object and try again." = "Could not import MCP JSON. Check the mcpServers object and try again."; "Could not inspect saved file" = "Could not inspect saved file"; "Could not inspect the selected history. Refresh the preview to retry." = "Could not inspect the selected history. Refresh the preview to retry."; @@ -1701,6 +1706,7 @@ "Install community plugin “%@”?" = "Install community plugin “%@”?"; "Install from repository" = "Install from repository"; "Install the selected channel" = "Install the selected channel"; +"Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes."; "Installation" = "Installation"; "Installation: %@" = "Installation: %@"; "Installed" = "Installed"; @@ -2556,6 +2562,7 @@ "Plugin %@ from the official repository is not signed by Lithe, so it was not installed." = "Plugin %@ from the official repository is not signed by Lithe, so it was not installed."; "Plugin %@ has been removed from Lithe. Uninstall the old package." = "Plugin %@ has been removed from Lithe. Uninstall the old package."; "Plugin %@ has no Lithe signature. Community plugins require confirmation before installation." = "Plugin %@ has no Lithe signature. Community plugins require confirmation before installation."; +"Plugin %@ has no approved package for %@. Install a compatible plugin version." = "Plugin %@ has no approved package for %@. Install a compatible plugin version."; "Plugin %@ has no previous version to restore." = "Plugin %@ has no previous version to restore."; "Plugin %@ has no setting %@." = "Plugin %@ has no setting %@."; "Plugin %@ is an unsigned development package. Only development builds of Lithe can load it." = "Plugin %@ is an unsigned development package. Only development builds of Lithe can load it."; @@ -2840,6 +2847,7 @@ "Remote Project" = "Remote Project"; "Remote URL" = "Remote URL"; "Remote folder" = "Remote folder"; +"Remote pending documents exceeded the startup limit. Wait for the language server to start." = "Remote pending documents exceeded the startup limit. Wait for the language server to start."; "Remote: %@" = "Remote: %@"; "Removal pending" = "Removal pending"; "Remove" = "Remove"; @@ -3072,6 +3080,7 @@ "Runs on %@ shipped with the plugin" = "Runs on %@ shipped with the plugin"; "Runs the %@ installed on this Mac through the %@ adapter." = "Runs the %@ installed on this Mac through the %@ adapter."; "Runtime" = "Runtime"; +"Runtime discovery failed" = "Runtime discovery failed"; "Runtime used by the language server (installed on this Mac)" = "Runtime used by the language server (installed on this Mac)"; "Rust Core cache-retention planning is unavailable." = "Rust Core cache-retention planning is unavailable."; "Rust Core returned a Java cache-retention key that was not observed." = "Rust Core returned a Java cache-retention key that was not observed."; @@ -3376,6 +3385,7 @@ "Solidity Support" = "Solidity Support"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; +"Some special files could not be copied" = "Some special files could not be copied"; "Sort by Last Updated" = "Sort by Last Updated"; "Sort by Name" = "Sort by Name"; "Sort plugins" = "Sort plugins"; @@ -3758,12 +3768,18 @@ "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually." = "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually."; "The pull request will return to the open list and can receive new reviews." = "The pull request will return to the open list and can receive new reviews."; "The rebase was aborted. Inspect the branch and working files before continuing your work." = "The rebase was aborted. Inspect the branch and working files before continuing your work."; +"The remote plugin package changes approved commands or permissions. Install and approve that version first." = "The remote plugin package changes approved commands or permissions. Install and approve that version first."; +"The remote plugin package is not part of the approved installation." = "The remote plugin package is not part of the approved installation."; +"The remote terminal closed before it started." = "The remote terminal closed before it started."; +"The remote terminal is busy. Wait before sending more input." = "The remote terminal is busy. Wait before sending more input."; +"The remote terminal must be started asynchronously." = "The remote terminal must be started asynchronously."; "The reported file changes have been updated. Review them again before rolling back." = "The reported file changes have been updated. Review them again before rolling back."; "The repository changed or another Git operation is running. Preview the patch again." = "The repository changed or another Git operation is running. Preview the patch again."; "The repository changed or another Git operation is running. Refresh before retrying." = "The repository changed or another Git operation is running. Refresh before retrying."; "The repository changed or another Git operation is running. Refresh the preview to retry." = "The repository changed or another Git operation is running. Refresh the preview to retry."; "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; "The request has no secret." = "The request has no secret."; +"The required tool is not available on the remote host." = "The required tool is not available on the remote host."; "The response stopped at the model's token limit." = "The response stopped at the model's token limit."; "The selected AI provider has an invalid API URL or model." = "The selected AI provider has an invalid API URL or model."; "The selected AI provider has no API key." = "The selected AI provider has no API key."; @@ -3796,6 +3812,7 @@ "The staged changes have no textual diff to summarize." = "The staged changes have no textual diff to summarize."; "The staged changes will be committed and the current branch will be pushed to its configured remote." = "The staged changes will be committed and the current branch will be pushed to its configured remote."; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; +"The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace."; "The test file is outside the current workspace: %@" = "The test file is outside the current workspace: %@"; "The update could not be installed. Download the release manually and try again." = "The update could not be installed. Download the release manually and try again."; "The update package could not be downloaded. Check your internet connection and try again." = "The update package could not be downloaded. Check your internet connection and try again."; @@ -3879,6 +3896,7 @@ "This folder already belongs to a Git repository" = "This folder already belongs to a Git repository"; "This folder cannot be read." = "This folder cannot be read."; "This folder is empty" = "This folder is empty"; +"This host cannot receive a compatible plugin package. Update the remote server." = "This host cannot receive a compatible plugin package. Update the remote server."; "This page" = "This page"; "This panel needs exactly one matching plugin instance in the workspace." = "This panel needs exactly one matching plugin instance in the workspace."; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."; @@ -7229,8 +7247,18 @@ "native.remoteFetchFailed" = "Remote Fetch failed"; "native.remoteFetchSucceeded" = "Remote Fetch succeeded"; "native.remoteFolder" = "Remote folder"; +"native.remoteGitMixedHosts" = "A Git operation cannot span different workspace hosts."; +"native.remoteGitMixedLocal" = "A Git operation cannot span local and remote workspaces."; "native.remoteJVMTomcat" = "Remote JVM / Tomcat"; "native.remoteProject" = "Remote Project"; +"native.remoteRuntimeDiscoveryFailed" = "Could not discover runtimes on the remote host. Check the connection and refresh."; +"native.remoteRuntimeToolMissing" = "The required tool is not available on the remote host."; +"native.remoteRuntimeToolRecovery" = "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes."; +"native.remoteShellDiscoveryFailed" = "Could not discover shells on the remote host. Check the connection and try again."; +"native.remoteTerminalAsyncStart" = "The remote terminal must be started asynchronously."; +"native.remoteTerminalClosedBeforeStart" = "The remote terminal closed before it started."; +"native.remoteTerminalInputBusy" = "The remote terminal is busy. Wait before sending more input."; +"native.remoteTerminalInvalidDirectory" = "The terminal directory is outside the remote workspace."; "native.remoteURL" = "Remote URL"; "native.removalPending" = "Removal pending"; "native.removeAll" = "Remove All"; @@ -7460,6 +7488,8 @@ "native.runsTests" = "Runs %1$@ tests"; "native.runsTheInstalledOnThisMacThroughTheAdapter" = "Runs the %1$@ installed on this Mac through the %2$@ adapter."; "native.runtime" = "Runtime"; +"native.runtimeDiscoveryFailed" = "Runtime discovery failed"; +"native.runtimeDiscoveryRecovery" = "Check the workspace connection and refresh the available runtimes."; "native.runtimeUsedByTheLanguageServerInstalledOnThisMac" = "Runtime used by the language server (installed on this Mac)"; "native.rustCoreCacheRetentionPlanningIsUnavailable" = "Rust Core cache-retention planning is unavailable."; "native.rustCoreReturnedAJavaCacheRetentionKey" = "Rust Core returned a Java cache-retention key that was not observed."; @@ -8687,6 +8717,12 @@ "punctuation and symbols" = "punctuation and symbols"; "quickOpen.filesCount" = "%1$lld files"; "rebase" = "rebase"; +"remote.copyIncomplete" = "Some special files could not be copied"; +"remote.pendingDocumentsLimit" = "Remote pending documents exceeded the startup limit. Wait for the language server to start."; +"remote.pluginApprovalRequired" = "The remote plugin package changes approved commands or permissions. Install and approve that version first."; +"remote.pluginHostUnsupported" = "This host cannot receive a compatible plugin package. Update the remote server."; +"remote.pluginPlatformUnavailable" = "Plugin %1$@ has no approved package for %2$@. Install a compatible plugin version."; +"remote.pluginUnapproved" = "The remote plugin package is not part of the approved installation."; "results" = "results"; "revert" = "revert"; "run configuration" = "run configuration"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b77d9373ae..eb789022cd 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -136,6 +136,8 @@ "8 spaces" = "8 个空格"; "; " = ";"; "A ChangeList with this name already exists." = "已存在同名的更改列表。"; +"A Git operation cannot span different workspace hosts." = "一次 Git 操作不能跨越不同的工作区主机。"; +"A Git operation cannot span local and remote workspaces." = "一次 Git 操作不能同时跨越本地和远程工作区。"; "A Hook with this name already exists." = "已存在同名 Hook。"; "A Java cache-retention target is not a removable directory." = "Java 缓存保留目标不是可删除的目录。"; "A Java project with no build tool, Maven, or Gradle." = "不使用构建工具,或使用 Maven、Gradle 的 Java 项目。"; @@ -532,6 +534,7 @@ "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)." = "请检查 Hook 名称、命令、事件、Agent 和超时(1–86400 秒)。"; "Check the MCP command, URL and headers." = "请检查 MCP 命令、URL 和请求头。"; "Check the configuration format, API URL and model. Each configuration must be under 64 KiB." = "请检查配置格式、API 地址和模型,每份配置不得超过 64 KiB。"; +"Check the workspace connection and refresh the available runtimes." = "请检查工作区连接并刷新可用运行环境。"; "Check your internet connection and try again later." = "请检查网络连接,稍后再试。"; "Check: %@" = "检查:%@"; "Checked %@" = "%@检查"; @@ -915,6 +918,8 @@ "Could not connect to the Debug Adapter: %@" = "无法连接调试适配器:%@"; "Could not connect to the Java Debug Server: %@" = "无法连接 Java Debug Server:%@"; "Could not copy your Stable settings" = "无法复制正式版设置"; +"Could not discover runtimes on the remote host. Check the connection and refresh." = "无法探测远程主机的运行环境,请检查连接后刷新。"; +"Could not discover shells on the remote host. Check the connection and try again." = "无法探测远程主机上的 Shell。请检查连接后重试。"; "Could not import MCP JSON. Check the mcpServers object and try again." = "无法导入 MCP JSON。请检查 mcpServers 对象后重试。"; "Could not inspect saved file" = "无法读取已保存的文件"; "Could not inspect the selected history. Refresh the preview to retry." = "无法检查所选历史。请刷新预览后重试。"; @@ -1701,6 +1706,7 @@ "Install community plugin “%@”?" = "安装社区插件“%@”?"; "Install from repository" = "从仓库安装"; "Install the selected channel" = "安装所选渠道版本"; +"Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。"; "Installation" = "安装来源"; "Installation: %@" = "安装来源:%@"; "Installed" = "已安装"; @@ -2556,6 +2562,7 @@ "Plugin %@ from the official repository is not signed by Lithe, so it was not installed." = "官方仓库中的插件 %@ 没有 Lithe 签名,因此未安装。"; "Plugin %@ has been removed from Lithe. Uninstall the old package." = "插件 %@ 已从 Lithe 中移除。请卸载旧的插件包。"; "Plugin %@ has no Lithe signature. Community plugins require confirmation before installation." = "插件 %@ 没有 Lithe 签名。社区插件安装前需要确认。"; +"Plugin %@ has no approved package for %@. Install a compatible plugin version." = "插件 %@ 没有适用于 %@ 的已批准包,请安装兼容版本。"; "Plugin %@ has no previous version to restore." = "插件 %@ 没有可恢复的上一个版本。"; "Plugin %@ has no setting %@." = "插件 %@ 没有设置项 %@。"; "Plugin %@ is an unsigned development package. Only development builds of Lithe can load it." = "插件 %@ 是未签名的开发包。只有 Lithe 的开发版本可以加载它。"; @@ -2840,6 +2847,7 @@ "Remote Project" = "远程项目"; "Remote URL" = "远程地址"; "Remote folder" = "远程文件夹"; +"Remote pending documents exceeded the startup limit. Wait for the language server to start." = "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。"; "Remote: %@" = "远程:%@"; "Removal pending" = "等待移除"; "Remove" = "移除"; @@ -3072,6 +3080,7 @@ "Runs on %@ shipped with the plugin" = "使用插件自带的 %@ 运行"; "Runs the %@ installed on this Mac through the %@ adapter." = "通过 %2$@ 适配器驱动这台 Mac 上已安装的 %1$@。"; "Runtime" = "运行时长"; +"Runtime discovery failed" = "运行环境探测失败"; "Runtime used by the language server (installed on this Mac)" = "语言服务器使用的运行时(本机已安装)"; "Rust Core cache-retention planning is unavailable." = "Rust Core 的缓存保留规划不可用。"; "Rust Core returned a Java cache-retention key that was not observed." = "Rust Core 返回了未被观察到的 Java 缓存保留键。"; @@ -3376,6 +3385,7 @@ "Solidity Support" = "Solidity 支持"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; +"Some special files could not be copied" = "部分特殊文件未能复制"; "Sort by Last Updated" = "按最近更新排序"; "Sort by Name" = "按名称排序"; "Sort plugins" = "插件排序"; @@ -3758,12 +3768,18 @@ "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually." = "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。"; "The pull request will return to the open list and can receive new reviews." = "该拉取请求将回到打开列表,并可继续接收审查。"; "The rebase was aborted. Inspect the branch and working files before continuing your work." = "变基已中止。继续工作前请检查分支和工作文件。"; +"The remote plugin package changes approved commands or permissions. Install and approve that version first." = "远程插件包更改了已批准的命令或权限,请先安装并批准该版本。"; +"The remote plugin package is not part of the approved installation." = "远程插件包不属于已批准的安装。"; +"The remote terminal closed before it started." = "远程终端在启动完成前已关闭。"; +"The remote terminal is busy. Wait before sending more input." = "远程终端正忙,请稍后再发送输入。"; +"The remote terminal must be started asynchronously." = "远程终端需要异步启动。"; "The reported file changes have been updated. Review them again before rolling back." = "Agent 上报的文件修改已更新,请重新确认后再回撤。"; "The repository changed or another Git operation is running. Preview the patch again." = "仓库已更改或有其他 Git 操作正在运行。请重新预览补丁。"; "The repository changed or another Git operation is running. Refresh before retrying." = "仓库已更改或有其他 Git 操作正在运行。请刷新后重试。"; "The repository changed or another Git operation is running. Refresh the preview to retry." = "仓库已更改或有其他 Git 操作正在运行。请刷新预览后重试。"; "The repository will be opened in detached HEAD state." = "仓库将以 detached HEAD 状态打开。"; "The request has no secret." = "请求不包含密钥。"; +"The required tool is not available on the remote host." = "远程主机上没有可用的所需工具。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; "The selected AI provider has an invalid API URL or model." = "所选 AI 服务商的 API 地址或模型无效。"; "The selected AI provider has no API key." = "所选 AI 服务商没有 API 密钥。"; @@ -3796,6 +3812,7 @@ "The staged changes have no textual diff to summarize." = "已暂存的更改没有可用于总结的文本 Diff。"; "The staged changes will be committed and the current branch will be pushed to its configured remote." = "暂存的更改将被提交,并推送当前分支到已配置的远程仓库。"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; +"The terminal directory is outside the remote workspace." = "终端目录不在远程工作区内。"; "The test file is outside the current workspace: %@" = "测试文件在当前工作区之外:%@"; "The update could not be installed. Download the release manually and try again." = "无法安装更新,请手动下载版本后重试。"; "The update package could not be downloaded. Check your internet connection and try again." = "无法下载更新包,请检查网络连接后重试。"; @@ -3879,6 +3896,7 @@ "This folder already belongs to a Git repository" = "此目录已属于一个 Git 仓库。"; "This folder cannot be read." = "无法读取此文件夹。"; "This folder is empty" = "此文件夹为空"; +"This host cannot receive a compatible plugin package. Update the remote server." = "此主机无法接收兼容的插件包,请更新远程服务。"; "This page" = "此页面"; "This panel needs exactly one matching plugin instance in the workspace." = "此面板要求工作区中恰好有一个匹配的插件实例。"; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; @@ -7229,8 +7247,18 @@ "native.remoteFetchFailed" = "远程 Fetch 失败"; "native.remoteFetchSucceeded" = "远程 Fetch 成功"; "native.remoteFolder" = "远程文件夹"; +"native.remoteGitMixedHosts" = "一次 Git 操作不能跨越不同的工作区主机。"; +"native.remoteGitMixedLocal" = "一次 Git 操作不能同时跨越本地和远程工作区。"; "native.remoteJVMTomcat" = "远程 JVM / Tomcat"; "native.remoteProject" = "远程项目"; +"native.remoteRuntimeDiscoveryFailed" = "无法探测远程主机的运行环境,请检查连接后刷新。"; +"native.remoteRuntimeToolMissing" = "远程主机上没有可用的所需工具。"; +"native.remoteRuntimeToolRecovery" = "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。"; +"native.remoteShellDiscoveryFailed" = "无法探测远程主机上的 Shell。请检查连接后重试。"; +"native.remoteTerminalAsyncStart" = "远程终端需要异步启动。"; +"native.remoteTerminalClosedBeforeStart" = "远程终端在启动完成前已关闭。"; +"native.remoteTerminalInputBusy" = "远程终端正忙,请稍后再发送输入。"; +"native.remoteTerminalInvalidDirectory" = "终端目录不在远程工作区内。"; "native.remoteURL" = "远程地址"; "native.removalPending" = "等待移除"; "native.removeAll" = "全部移除"; @@ -7460,6 +7488,8 @@ "native.runsTests" = "运行 %1$@ 测试"; "native.runsTheInstalledOnThisMacThroughTheAdapter" = "通过 %2$@ 适配器驱动这台 Mac 上已安装的 %1$@。"; "native.runtime" = "运行时长"; +"native.runtimeDiscoveryFailed" = "运行环境探测失败"; +"native.runtimeDiscoveryRecovery" = "请检查工作区连接并刷新可用运行环境。"; "native.runtimeUsedByTheLanguageServerInstalledOnThisMac" = "语言服务器使用的运行时(本机已安装)"; "native.rustCoreCacheRetentionPlanningIsUnavailable" = "Rust Core 的缓存保留规划不可用。"; "native.rustCoreReturnedAJavaCacheRetentionKey" = "Rust Core 返回了未被观察到的 Java 缓存保留键。"; @@ -8687,6 +8717,12 @@ "punctuation and symbols" = "标点与符号"; "quickOpen.filesCount" = "%1$lld 个文件"; "rebase" = "变基"; +"remote.copyIncomplete" = "部分特殊文件未能复制"; +"remote.pendingDocumentsLimit" = "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。"; +"remote.pluginApprovalRequired" = "远程插件包更改了已批准的命令或权限,请先安装并批准该版本。"; +"remote.pluginHostUnsupported" = "此主机无法接收兼容的插件包,请更新远程服务。"; +"remote.pluginPlatformUnavailable" = "插件 %1$@ 没有适用于 %2$@ 的已批准包,请安装兼容版本。"; +"remote.pluginUnapproved" = "远程插件包不属于已批准的安装。"; "results" = "个结果"; "revert" = "反向提交"; "run configuration" = "运行配置"; diff --git a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift index 6c8fd7c492..6cc292757a 100644 --- a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift +++ b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts // Note: SSH 连接配置与测试连接见 .agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md @@ -170,12 +171,16 @@ struct RemoteDirectoryEntry: Decodable, Equatable, Identifiable, Sendable { let size: Int64? /// Modification time in milliseconds since the Unix epoch. let modifiedMs: Int64? + let mode: UInt32? + let symlinkTargetMode: UInt32? - init(kind: String, symlinkTargetKind: String? = nil, size: Int64? = nil, modifiedMs: Int64? = nil) { + init(kind: String, symlinkTargetKind: String? = nil, size: Int64? = nil, modifiedMs: Int64? = nil, mode: UInt32? = nil, symlinkTargetMode: UInt32? = nil) { self.kind = kind self.symlinkTargetKind = symlinkTargetKind self.size = size self.modifiedMs = modifiedMs + self.mode = mode + self.symlinkTargetMode = symlinkTargetMode } } @@ -228,4 +233,146 @@ protocol RemoteWorkspaceTransport: Sendable { func writeFile( _ data: Data, to path: String, expected: RemoteFileVersion?, session id: String ) throws -> RemoteWriteOutcome + func mutate(_ mutation: RemoteFileMutation, session: String) throws + func watch( + root: String, session: String, + onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void + ) async throws +} + +enum RemoteFileMutation: Sendable, Equatable { + case createDirectory(path: String, recursive: Bool) + case copy(from: String, to: String) + case move(from: String, to: String) + case remove(path: String) + case trash(path: String) +} + +enum RemoteWatchEvent: Sendable, Equatable { + /// A new subscription needs a rescan to cover the startup window. + case started + case changed(paths: [String], overflow: Bool) + case invalidated(rootRemoved: Bool) +} + +extension RemoteWorkspaceTransport { + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + throw CocoaError(.featureUnsupported) + } + + func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + throw CocoaError(.featureUnsupported) + } +} + +/// A PTY on the workspace host. Environment contains explicit overrides only; +/// the remote account owns HOME, PATH, shell selection and login profiles. +struct RemoteTerminalRequest: Encodable, Equatable, Sendable { + let argv: [String]? + let cwd: String + let env: [String: String] + let columns: UInt16 + let rows: UInt16 +} + +enum RemoteTerminalEvent: Equatable, Sendable { + case started(id: UInt64, pid: Int32) + case output(Data) + case exited(Int32?) +} + +protocol RemoteTerminalConnecting: Sendable { + /// Owns the PTY until exit. Cancelling this task closes its entire process tree. + func runTerminal( + _ request: RemoteTerminalRequest, session: String, + onEvent: @escaping @Sendable (RemoteTerminalEvent) -> Void + ) async -> Result + func writeTerminal(_ data: Data, id: UInt64, session: String) async throws + func resizeTerminal(id: UInt64, columns: UInt16, rows: UInt16, session: String) async throws +} + +/// A non-interactive process with an explicit host working directory. +struct RemoteProcessRequest: Encodable, Equatable, Sendable { + let argv: [String] + let cwd: String + let env: [String: String] + let environment = "login" + let processId = 0 +} + +enum RemoteProcessEvent: Equatable, Sendable { + case output(Data, standardError: Bool) +} + +struct RemoteProcessResult: Decodable, Equatable, Sendable { + let exitCode: Int32? + let signal: Int32? +} + +protocol RemoteProcessRunning: Sendable { + /// Owns the process tree through exit, timeout, or cancellation. Output is + /// delivered in host order and stderr remains distinct from stdout. + func runProcess( + _ request: RemoteProcessRequest, session: String, timeout: Duration?, + onEvent: @escaping @Sendable (RemoteProcessEvent) -> Void + ) async throws -> RemoteProcessResult +} + +struct RemoteBuildFileMetadata: Equatable, Sendable { + let isExecutable: Bool +} + +protocol RemoteBuildFileProbing: Sendable { + /// Resolves links on the host. Only a missing file returns nil; transport + /// and permission failures remain failures. Calls belong on a worker. + func buildFileMetadata(_ path: String, session: String) throws -> RemoteBuildFileMetadata? +} + +/// Host-owned mutable build state. Blocking operations run on a platform worker. +protocol RemoteBuildStateAccess: Sendable { + func prepareBuildState(pluginID: String, buildSystemID: String, workspacePath: String, session: String) throws -> String + func removeBuildOutputFile(_ path: String, session: String) throws +} + +/// Immutable plugin snapshots use the same generic host filesystem as builds. +/// Trust and target-platform selection remain in the package adapter. +struct RemotePluginHost: Decodable, Equatable, Sendable { + let os: String + let arch: String + let homeDirectory: String? + let features: [String] + + var platform: String? { + let system: String + switch os { case "linux": system = "linux"; case "macos": system = "darwin"; default: return nil } + switch arch { case "x86_64": return system + "-x64"; case "aarch64": return system + "-arm64"; default: return nil } + } + + var templateArchitecture: String { arch == "aarch64" ? "arm64" : arch } +} + +protocol RemotePluginFileAccess: Sendable { + func pluginHost(session: String) async throws -> RemotePluginHost + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws +} + +protocol RemotePluginPackageDeploying: Sendable { + func buildRequiresPackage(pluginID: String, buildSystemID: String, packageURL: URL) throws -> Bool + func deploy(pluginID: String, packageURL: URL, session: String) async throws -> RemoteDeployedPluginPackage +} + +struct RemoteDeployedPluginPackage: Sendable { + let packageURL: URL + let manifest: Data + let architecture: String + let release: @Sendable () async -> Void +} + +/// Read-only shell discovery on one host. Calls block and belong on a worker. +protocol RemoteShellProbing: Sendable { + func shellEnvironment(session: String) throws -> [String: String] + func registeredShells(session: String) throws -> String + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] + func runtimeExecutable(_ path: String, session: String) throws -> Bool } diff --git a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift index a8324e054f..6132a5d98d 100644 --- a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift +++ b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift @@ -66,8 +66,15 @@ struct DefaultRuntimeToolDiscovery: RuntimeToolDiscovery { } protocol RuntimeLocator: Sendable { + /// A failed host probe must not be presented as an empty local toolchain. + var discoveryFailure: String? { get } + func normalizedPath(_ path: String, relativeTo root: URL?) -> String + func configureProbePaths(_ paths: [String]) + /// Remote adapters select from a host-read snapshot without UI-thread I/O. + func selectJavaRuntime(candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result? func environment() -> [String: String] func discover() -> RuntimeDiscoveryResult + func discover(isCancelled: @Sendable () -> Bool) -> RuntimeDiscoveryResult /// Java-only probing for launch paths; never runs Maven or other tools. func discoverJavaRuntimes() -> [JavaRuntimeCandidate] func discoverJavaRuntimes(isCancelled: @Sendable () -> Bool) -> [JavaRuntimeCandidate] @@ -84,6 +91,17 @@ protocol RuntimeLocator: Sendable { } extension RuntimeLocator { + var discoveryFailure: String? { nil } + func configureProbePaths(_ paths: [String]) {} + func selectJavaRuntime(candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result? { nil } + func normalizedPath(_ path: String, relativeTo root: URL?) -> String { + let normalized = ((path as NSString).expandingTildeInPath as NSString).standardizingPath + guard !(normalized as NSString).isAbsolutePath, let root else { return normalized } + return root.appendingPathComponent(normalized).standardizedFileURL.path + } + func discover(isCancelled: @Sendable () -> Bool) -> RuntimeDiscoveryResult { + isCancelled() ? RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) : discover() + } func invalidateProbeCache() {} func discoverJavaRuntimes(isCancelled: @Sendable () -> Bool) -> [JavaRuntimeCandidate] { isCancelled() ? [] : discoverJavaRuntimes() @@ -92,6 +110,16 @@ extension RuntimeLocator { func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? { nil } } +/// Synchronous host I/O used exclusively by runtime discovery's owned worker. +protocol RemoteRuntimeProbing: Sendable { + func runtimeEnvironment(session: String) throws -> [String: String] + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] + func runtimeRealPath(_ path: String, session: String) throws -> String + func runtimeExecutable(_ path: String, session: String) throws -> Bool + func runtimeVersion(_ executable: String, session: String) throws -> String + func runtimeRequirements(_ root: String, session: String) throws -> ToolingJSONValue +} + /// Shared selection policy; platform locators still own executable validation /// and version probing. Requirements are read through Core's existing document. protocol JavaRuntimeSelecting: Sendable { diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 84b2ca699f..2b84905ebf 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -220,6 +220,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { payload: BuildPayload( manifest: try Self.json(target.manifest), buildSystemID: target.buildSystemID, + workspaceRoot: target.workspaceURL?.path, request: request ) ) @@ -274,6 +275,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { private struct BuildPayload: Encodable { let manifest: ToolingJSONValue let buildSystemID: String + let workspaceRoot: String? let request: Request } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift new file mode 100644 index 0000000000..0599c1b173 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift @@ -0,0 +1,63 @@ +import Foundation +import LitheModuleAPI + +extension RustCoreBridge { + /// Only schema-owned root fields select a host. Commit messages, command + /// arguments and user text must never be interpreted as filesystem paths. + func remoteWorkspace( + for command: String, payload: Payload + ) throws -> RemoteWorkspace? { + guard let remoteWorkspaces, + command.hasPrefix("git.") || command.hasPrefix("build.") || [ + "workspace.repositories", "workspace.snapshot", "workspace.listDirectories", + "workspace.search", "workspace.searchEverywhere", "file.read", "file.write", + "workspace.searchIndex.warm", "workspace.searchIndex.update", "workspace.searchIndex.invalidate", + "workspace.replacePreview", "runConfig.selectJava", "lsp.startServer" + ].contains(command) else { return nil } + let encoded = try JSONEncoder().encode(payload) + guard let object = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { return nil } + var roots = [String]() + if let root = object["root"] as? String, !root.isEmpty { roots.append(root) } + if command.hasPrefix("build.") { + if let root = object["workspaceRoot"] as? String { roots.append(root) } + if let request = object["request"] as? [String: Any], + let root = request["workspaceRoot"] as? String { roots.append(root) } + } + if command == "lsp.startServer", let root = object["workingDirectory"] as? String { + roots.append(root) + } + roots.append(contentsOf: object["repositoryRoots"] as? [String] ?? []) + func appendRepositories(_ object: [String: Any]) { + let repositories = object["repositories"] as? [[String: Any]] ?? [] + roots.append(contentsOf: repositories.compactMap { $0["root"] as? String }) + } + appendRepositories(object) + if let session = object["session"] as? [String: Any], + let plan = session["plan"] as? [String: Any] { appendRepositories(plan) } + if let plan = object["plan"] as? [String: Any] { appendRepositories(plan) } + if let previous = object["previous"] as? [String: Any], + let plan = previous["plan"] as? [String: Any] { appendRepositories(plan) } + if let reviewed = object["reviewed"] as? [String: Any] { appendRepositories(reviewed) } + var selected: RemoteWorkspace? + var includesLocal = false + for root in roots { + guard let resolved = remoteWorkspaces.resolve(URL(fileURLWithPath: root)) else { + includesLocal = true + continue + } + let candidate = RemoteWorkspace(sessionId: resolved.binding.sessionID, + localRoot: resolved.binding.root.standardizedFileURL.path, + remoteRoot: resolved.binding.remoteRoot) + if let selected, selected != candidate { + throw CoreCallError(code: "not_supported", + message: L10n.string("A Git operation cannot span different workspace hosts."), details: nil) + } + selected = candidate + } + if selected != nil && includesLocal { + throw CoreCallError(code: "not_supported", + message: L10n.string("A Git operation cannot span local and remote workspaces."), details: nil) + } + return selected + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 4f837e5b44..db8ff621d8 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -12,6 +12,13 @@ import LitheRustCore struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { var gitPreferences: GitExecutionPreferences? = nil var gitExecutionJournal: GitExecutionJournal? = nil + var remoteWorkspaces: RemoteWorkspaceRegistry? = nil + + struct RemoteWorkspace: Encodable, Equatable { + let sessionId: String + let localRoot: String + let remoteRoot: String + } private struct Request: Encodable { let id: String @@ -20,6 +27,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let command: String let payload: Payload var gitExecution: GitExecutionOptions? = nil + var remoteWorkspace: RemoteWorkspace? = nil } private struct Envelope: Decodable { @@ -2806,6 +2814,20 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { )) } + /// Snapshot selection performs no filesystem access, including for a remote project. + func selectJavaRuntime( + requirements: ToolingJSONValue, candidates: [AutomaticJavaCandidate], fallbackID: String? + ) -> Result { + struct Payload: Encodable { + let requirements: ToolingJSONValue + let candidates: [AutomaticJavaCandidate] + let fallbackId: String? + } + return executeResult(command: "runConfig.selectJava", payload: Payload( + requirements: requirements, candidates: candidates, fallbackId: fallbackID + )) + } + func inspectRunConfiguration( at rootURL: URL, checkFingerprint: Bool? = nil, @@ -4181,12 +4203,14 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { command: String, payload: Payload, operationID: String? = nil, + timeoutMilliseconds: Int? = nil, onEvent: (@Sendable (String) -> Void)? = nil ) -> Result { let outcome: Result, CoreCallError> = decodeEnvelope( command: command, payload: payload, operationID: operationID, + timeoutMilliseconds: timeoutMilliseconds, onEvent: onEvent ) return outcome.flatMap { envelope in @@ -4216,6 +4240,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { command: String, payload: Payload, operationID: String? = nil, + timeoutMilliseconds: Int? = nil, onEvent: (@Sendable (String) -> Void)? = nil ) -> Result, CoreCallError> { guard isAvailable else { @@ -4234,14 +4259,23 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { var gitOptions = gitPreferences?.snapshot ?? GitExecutionOptions() gitOptions.interactive = execution != nil gitOptions.source = execution?.source ?? GitExecutionSource.current + let remoteWorkspace: RemoteWorkspace? + do { + remoteWorkspace = try self.remoteWorkspace(for: command, payload: payload) + } catch let failure as CoreCallError { + return .failure(failure) + } catch { + return .failure(CoreCallError(code: "invalid_request", message: "Invalid workspace request", details: nil)) + } guard let requestData = try? JSONEncoder().encode( Request( id: requestID, operationId: requestID, - timeoutMilliseconds: nil, + timeoutMilliseconds: timeoutMilliseconds, command: command, payload: payload, - gitExecution: (observesGit || command == "workspace.searchEverywhere") ? gitOptions : nil + gitExecution: (observesGit || command == "workspace.searchEverywhere") ? gitOptions : nil, + remoteWorkspace: remoteWorkspace ) ), let request = String(data: requestData, encoding: .utf8), diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 5047c42e63..befa305aea 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -3,6 +3,10 @@ import LitheCoreContracts import LitheModuleAPI extension RustCoreBridge: LanguageServerRuntimeCore { + func requiresBackgroundLanguageServerStart(rootURL: URL) -> Bool { + remoteWorkspaces?.resolve(rootURL) != nil + } + func startLanguageServer( providerID: String, executableURL: URL, diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift new file mode 100644 index 0000000000..7c98936959 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift @@ -0,0 +1,37 @@ +import Foundation +import LitheCoreContracts + +extension RustRemoteConnectionOperations: RemotePluginFileAccess { + func pluginHost(session: String) async throws -> RemotePluginHost { + let result: Result = await run( + "remote.hostInfo", payload: ["sessionId": session], onPrompt: { _ in }) + try Task.checkCancellation() + return try result.get() + } + + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue { + struct Payload: Encodable, Sendable { + let sessionId: String + let method: String + let params: ToolingJSONValue + } + let result: Result = await run( + "remote.hostRequest", payload: Payload(sessionId: session, method: method, params: params), onPrompt: { _ in }) + try Task.checkCancellation() + return try result.get() + } + + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws { + struct Payload: Encodable, Sendable { + let sessionId: String + let sourcePath: String + let path: String + let sha256: String + let mode: UInt32 + } + let result: Result = await run("remote.uploadFile", + payload: Payload(sessionId: session, sourcePath: source.path, path: path, sha256: sha256, mode: mode), onPrompt: { _ in }) + try Task.checkCancellation() + _ = try result.get() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift new file mode 100644 index 0000000000..4e50a19543 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift @@ -0,0 +1,39 @@ +import Foundation +import LitheCoreContracts + +extension RustRemoteConnectionOperations: RemoteProcessRunning { + func runProcess( + _ request: RemoteProcessRequest, session: String, timeout: Duration?, + onEvent: @escaping @Sendable (RemoteProcessEvent) -> Void + ) async throws -> RemoteProcessResult { + struct Payload: Encodable, Sendable { + let sessionId: String + let params: RemoteProcessRequest + let timeoutMilliseconds: UInt64? + } + struct Event: Decodable { + struct Parameters: Decodable { let stream: String; let data: String } + let type: String + let event: String + let params: Parameters + } + let milliseconds = timeout.map { duration -> UInt64 in + let parts = duration.components + let seconds = max(0, parts.seconds) + let fractional = max(0, parts.attoseconds / 1_000_000_000_000_000) + return UInt64(min(seconds, 86_400)) * 1_000 + UInt64(fractional) + } + let result: Result = await run( + "remote.runProcess", + payload: Payload(sessionId: session, params: request, timeoutMilliseconds: milliseconds), + onPrompt: { _ in }, onEvent: { raw in + guard let event = try? JSONDecoder().decode(Event.self, from: Data(raw.utf8)), + event.type == "remote", event.event == "process.output", + let bytes = Data(base64Encoded: event.params.data) else { return } + onEvent(.output(bytes, standardError: event.params.stream == "stderr")) + } + ) + try Task.checkCancellation() + return try result.get() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift new file mode 100644 index 0000000000..af80ed56e9 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift @@ -0,0 +1,150 @@ +import CryptoKit +import Foundation +import LitheCoreContracts + +extension RustRemoteConnectionOperations: RemoteRuntimeProbing, RemoteShellProbing, RemoteBuildFileProbing, RemoteBuildStateAccess { + private struct HostProbe: Encodable { + let sessionId: String + let method: String + let params: Parameters + } + + private func runtimeRequest( + _ method: String, params: Parameters, session: String + ) throws -> Value { + let result: Result = core.executeResult( + command: "remote.hostRequest", payload: HostProbe(sessionId: session, method: method, params: params), + timeoutMilliseconds: 15_000 + ) + return try result.get() + } + + func runtimeEnvironment(session: String) throws -> [String: String] { + struct Environment: Decodable { let environment: [String: String] } + let value: Environment = try runtimeRequest("environment.resolve", params: [String: String](), session: session) + return value.environment + } + + func shellEnvironment(session: String) throws -> [String: String] { + struct Environment: Decodable { let environment: [String: String] } + let value: Environment = try runtimeRequest("environment.resolve", params: ["refresh": true], session: session) + return value.environment + } + + func registeredShells(session: String) throws -> String { + struct Content: Decodable { let data: String; let eof: Bool } + struct Parameters: Encodable { let path = "/etc/shells"; let length = 65_536 } + do { + let value: Content = try runtimeRequest("fs.readFile", params: Parameters(), session: session) + guard value.eof, let bytes = Data(base64Encoded: value.data), bytes.count <= 65_536, + let text = String(data: bytes, encoding: .utf8) else { + throw RustCoreBridge.CoreCallError(code: "parse_failed", message: "Invalid registered shells snapshot", details: nil) + } + return text + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + return "" + } + } + + func runtimeRequirements(_ root: String, session: String) throws -> ToolingJSONValue { + struct Parameters: Encodable { let path: String; let length = 262_144 } + struct Content: Decodable { + struct Metadata: Decodable { let size: Int } + let data: String + let metadata: Metadata + let eof: Bool + } + do { + let value: Content = try runtimeRequest( + "fs.readFile", params: Parameters(path: root + "/.lithe/toolchains/requirements.json"), session: session + ) + guard value.eof, value.metadata.size <= 262_144, let data = Data(base64Encoded: value.data) else { + throw RustCoreBridge.CoreCallError(code: "parse_failed", message: "Invalid toolchain requirements snapshot", details: nil) + } + return try JSONDecoder().decode(ToolingJSONValue.self, from: data) + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + return .object(["version": .integer(1), "toolchains": .object([:])]) + } + } + + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] { + struct Listing: Decodable { let entries: [RemoteDirectoryEntry] } + let value: Listing = try runtimeRequest("fs.readDir", params: ["path": path], session: session) + return value.entries + } + + func runtimeRealPath(_ path: String, session: String) throws -> String { + struct Resolved: Decodable { let path: String } + let value: Resolved = try runtimeRequest("fs.realPath", params: ["path": path], session: session) + return value.path + } + + func runtimeExecutable(_ path: String, session: String) throws -> Bool { + struct Metadata: Decodable { let kind: String; let mode: UInt32 } + let resolved = try runtimeRealPath(path, session: session) + let value: Metadata = try runtimeRequest("fs.stat", params: ["path": resolved], session: session) + return value.kind == "file" && value.mode & 0o111 != 0 + } + + func buildFileMetadata(_ path: String, session: String) throws -> RemoteBuildFileMetadata? { + struct Metadata: Decodable { let kind: String; let mode: UInt32 } + do { + let resolved = try runtimeRealPath(path, session: session) + let value: Metadata = try runtimeRequest("fs.stat", params: ["path": resolved], session: session) + return RemoteBuildFileMetadata(isExecutable: value.kind == "file" && value.mode & 0o111 != 0) + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + return nil + } + } + + func prepareBuildState(pluginID: String, buildSystemID: String, workspacePath: String, session: String) throws -> String { + let environment = try runtimeEnvironment(session: session) + guard let home = environment["HOME"], home.hasPrefix("/"), !home.contains("\0") else { + throw CocoaError(.fileNoSuchFile) + } + func digest(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + let path = URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".lithe-server/state/plugins") + .appendingPathComponent(digest(pluginID)) + .appendingPathComponent("build-systems") + .appendingPathComponent(digest(buildSystemID)) + .appendingPathComponent("workspaces") + .appendingPathComponent(digest(workspacePath)).path + let _: ToolingJSONValue = try runtimeRequest("fs.createDir", params: ["path": ToolingJSONValue.string(path), "recursive": .bool(true), "mode": .integer(0o700)], session: session) + return path + } + + func removeBuildOutputFile(_ path: String, session: String) throws { + do { + let _: ToolingJSONValue = try runtimeRequest("fs.remove", params: ["path": ToolingJSONValue.string(path), "recursive": .bool(false)], session: session) + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + // A command may finish without writing its optional output file. + } + } + + func runtimeVersion(_ executable: String, session: String) throws -> String { + struct Spawn: Encodable { + let processId = 0 + let argv: [String] + let environment = "login" + } + struct Capture: Encodable { + let sessionId: String + let params: Spawn + let timeoutMilliseconds = 5_000 + let maxOutputBytes = 262_144 + } + struct Output: Decodable { let stdout: String; let stderr: String; let exitCode: Int? } + let result: Result = core.executeResult( + command: "remote.captureProcess", + payload: Capture(sessionId: session, params: Spawn(argv: [executable, "-version"])), + timeoutMilliseconds: 7_000 + ) + let value = try result.get() + guard value.exitCode == 0, let stdout = Data(base64Encoded: value.stdout), + let stderr = Data(base64Encoded: value.stderr) else { return "" } + return String(decoding: stdout, as: UTF8.self) + "\n" + String(decoding: stderr, as: UTF8.self) + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift index f9df960eeb..8b27b090e7 100644 --- a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift @@ -1,8 +1,10 @@ import Foundation +import LitheCoreContracts +import LitheModuleAPI /// Typed `remote.*` commands. Rust Core runs OpenSSH and /// relays its questions; this adapter only translates payloads and events. -struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionConnecting, RemoteWorkspaceTransport { +struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionConnecting, RemoteWorkspaceTransport, RemoteTerminalConnecting { let core: RustCoreBridge func probe( @@ -50,10 +52,11 @@ struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionCon /// Runs one blocking Core command off the caller's thread. Cancelling /// the calling task cancels the command. - private func run( + func run( _ command: String, payload: Payload, - onPrompt: @escaping @Sendable (RemoteAuthenticationPrompt) -> Void + onPrompt: @escaping @Sendable (RemoteAuthenticationPrompt) -> Void, + onEvent: @escaping @Sendable (String) -> Void = { _ in } ) async -> Result { let operationID = UUID().uuidString let cancellation = RustCoreCancellation(core: core, operationID: operationID) @@ -69,6 +72,7 @@ struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionCon operationID: operationID, onEvent: { event in cancellation.receive(event) + onEvent(event) if let prompt = Self.prompt(from: event) { onPrompt(prompt) } } ) @@ -125,6 +129,132 @@ struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionCon return .saved(RemoteFileVersion(size: size, modifiedMs: result.modifiedMs)) } + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + let method: String + let params: [String: ToolingJSONValue] + switch mutation { + case .createDirectory(let path, let recursive): + method = "fs.createDir" + params = ["path": .string(path), "recursive": .bool(recursive)] + case .copy(let from, let to): + method = "fs.copy" + params = ["from": .string(from), "to": .string(to), "recursive": .bool(true)] + case .move(let from, let to): + method = "fs.rename" + params = ["from": .string(from), "to": .string(to)] + case .remove(let path): + method = "fs.remove" + params = ["path": .string(path), "recursive": .bool(true)] + case .trash(let path): + method = "fs.trash" + params = ["path": .string(path)] + } + let value: ToolingJSONValue = try blocking( + "remote.hostRequest", payload: HostPayload(sessionId: session, method: method, params: params) + ) + if case .object(let result) = value, + case .number(let skipped) = result["skippedSpecialFiles"], skipped > 0 { + throw RemoteProbeFailure(code: "copy_incomplete", message: L10n.string("Some special files could not be copied")) + } + } + + func watch( + root: String, session: String, + onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void + ) async throws { + struct Parameters: Encodable, Sendable { let root: String } + struct Payload: Encodable, Sendable { let sessionId: String; let params: Parameters } + struct Event: Decodable { + struct Parameters: Decodable { + struct Change: Decodable { let path: String } + let changes: [Change]? + let overflow: Bool? + let code: String? + } + let type: String + let event: String? + let params: Parameters? + } + let result: Result = await run( + "remote.runWatch", payload: Payload(sessionId: session, params: Parameters(root: root)), + onPrompt: { _ in }, onEvent: { raw in + guard let event = try? JSONDecoder().decode(Event.self, from: Data(raw.utf8)), + event.type == "remote" else { return } + switch event.event { + case "watch.started": onEvent(.started) + case "watch.changed": + onEvent(.changed(paths: event.params?.changes?.map(\.path) ?? [], overflow: event.params?.overflow ?? true)) + case "watch.error": onEvent(.invalidated(rootRemoved: event.params?.code == "ROOT_REMOVED")) + default: break + } + } + ) + _ = try result.get() + } + + func runTerminal( + _ request: RemoteTerminalRequest, session: String, + onEvent: @escaping @Sendable (RemoteTerminalEvent) -> Void + ) async -> Result { + struct Payload: Encodable, Sendable { let sessionId: String; let params: RemoteTerminalRequest } + struct Exit: Decodable, Sendable { let exitCode: Int32? } + struct Event: Decodable { + struct Parameters: Decodable { + let terminalId: UInt64? + let pid: Int32? + let data: String? + let exitCode: Int32? + } + let type: String + let event: String? + let params: Parameters? + } + let result: Result = await run( + "remote.runTerminal", payload: Payload(sessionId: session, params: request), + onPrompt: { _ in }, onEvent: { raw in + guard let event = try? JSONDecoder().decode(Event.self, from: Data(raw.utf8)), + event.type == "remote", let params = event.params else { return } + switch event.event { + case "terminal.started": + if let id = params.terminalId, let pid = params.pid { onEvent(.started(id: id, pid: pid)) } + case "terminal.output": + if let encoded = params.data, let data = Data(base64Encoded: encoded) { onEvent(.output(data)) } + case "terminal.exit": onEvent(.exited(params.exitCode)) + default: break + } + } + ) + return result.map { _ in () } + } + + func writeTerminal(_ data: Data, id: UInt64, session: String) async throws { + struct Parameters: Encodable, Sendable { let terminalId: UInt64; let data: String } + try await terminalControl("terminal.write", params: Parameters(terminalId: id, data: data.base64EncodedString()), session: session) + } + + func resizeTerminal(id: UInt64, columns: UInt16, rows: UInt16, session: String) async throws { + struct Parameters: Encodable, Sendable { let terminalId: UInt64; let columns: UInt16; let rows: UInt16 } + try await terminalControl("terminal.resize", params: Parameters(terminalId: id, columns: columns, rows: rows), session: session) + } + + private struct HostPayload: Encodable, Sendable { + let sessionId: String + let method: String + let params: Parameters + } + + private func terminalControl( + _ method: String, params: Parameters, session: String + ) async throws { + // Control methods return JSON null; the JSON value decoder preserves + // this successful response without requiring a synthetic data object. + let payload = HostPayload(sessionId: session, method: method, params: params) + let result: Result = await run( + "remote.hostRequest", payload: payload, onPrompt: { _ in } + ) + _ = try result.get() + } + private func blocking(_ command: String, payload: Payload) throws -> Value { let result: Result = core.executeResult(command: command, payload: payload) return try result.mapError { RemoteProbeFailure(code: $0.code, message: $0.message) }.get() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index f42646cc5b..6235423301 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -15,7 +15,9 @@ extension AppModel { @MainActor func activateTerminalModule() async -> Bool { - await terminalModuleCoordinator.activate() + let activated = await terminalModuleCoordinator.activate() + if activated { terminalFeature?.setShellWorkspace(workspaceURL) } + return activated } func toggleTerminal() { @@ -61,7 +63,8 @@ extension AppModel { } let session = feature.createSession( in: workspaceURL, - shellPath: shellPath ?? settings.terminalShellPath, + shellPath: shellPath ?? (services.remoteConnections?.workspaces.contains(workspaceURL) == true + ? nil : settings.terminalShellPath), preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent }, showsWorkbenchBackground: workbenchBackgroundFeature.hasImage ) @@ -151,8 +154,9 @@ extension AppModel { TerminalEnvironmentChange(name: $0.name, value: $0.value) } ) - let created = try feature.createProcessSession( + let created = try await feature.createProcessSessionAsync( launch, + workspaceURL: workspaceURL, showsWorkbenchBackground: workbenchBackgroundFeature.hasImage ) { [weak self] output in self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output) @@ -165,7 +169,8 @@ extension AppModel { debugTerminalSessionIDsByDebugSession[debugSessionID, default: []].insert(created.session.id) activeDebugTerminalSessionIDsByDebugSession[debugSessionID] = created.session.id } - if let id = debugSessionID, executablePath.hasSuffix("/java") { + if let id = debugSessionID, executablePath.hasSuffix("/java"), + services.remoteConnections?.workspaces.contains(workspaceURL) != true { debugPerformanceFeature.register(DebugPerformanceTarget(processID: created.processID, javaExecutable: URL(fileURLWithPath: executablePath)), sessionID: id.rawValue) } @@ -400,7 +405,10 @@ extension AppModel { } var activeTerminalShellPath: String { - settings.terminalShellPath ?? terminalFeature?.availableShells.first ?? "/bin/zsh" + if let workspaceURL, services.remoteConnections?.workspaces.contains(workspaceURL) == true { + return terminalFeature?.availableShells.first ?? "" + } + return settings.terminalShellPath ?? terminalFeature?.availableShells.first ?? "/bin/zsh" } private func sessions(orderedBy sessionIDs: [UUID]) -> [TerminalSession] { diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index ddc23a34e8..d6e6a52f92 100644 --- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -311,6 +311,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable { case ready case jdkMissing case configuredJDKInvalid(path: String) + case probeFailed(message: String) var requiresAttention: Bool { self != .checking && self != .ready @@ -318,7 +319,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable { var blocksJavaRun: Bool { switch self { - case .jdkMissing, .configuredJDKInvalid: true + case .jdkMissing, .configuredJDKInvalid, .probeFailed: true case .checking, .ready: false } } @@ -345,6 +346,7 @@ struct JavaEnvironmentReport: Equatable, Sendable { case .ready: "Java environment ready" case .jdkMissing: "JDK not found" case .configuredJDKInvalid: "Configured JDK is invalid" + case .probeFailed: "Runtime discovery failed" } } @@ -358,6 +360,7 @@ struct JavaEnvironmentReport: Equatable, Sendable { "This project contains Java sources, but no usable JDK was detected." case .configuredJDKInvalid(let path): "The configured JDK path is not a valid JDK: \(path)" + case .probeFailed(let message): message } } @@ -368,6 +371,8 @@ struct JavaEnvironmentReport: Equatable, Sendable { "Choose a JDK in Settings → Project or install a full JDK and set JAVA_HOME." case .configuredJDKInvalid: "Choose another JDK in Settings → Project or clear the invalid path." + case .probeFailed: + "Check the workspace connection and refresh the available runtimes." } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index a108325da3..5fe9fe05e8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -34,6 +34,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { private let versionChecker: any LanguageServerRuntimeVersionChecking private let runtime: any LanguageToolRuntimePort private let core: any LanguageServerRuntimeCore + private let remoteLaunches: MacRemotePluginLaunchPreparer? private weak var processRegistry: (any LanguageServerProcessRegistry)? /// Ends the processes carrying a backend's token; `immediately` skips the /// graceful stop. See `MacPluginBackendProcesses`. @@ -47,11 +48,13 @@ final class MacBuildProviderSessions: BuildProviderRequesting { let id = UUID() /// The plugin's setting values the launch was expanded with. let settings: [String: ToolingJSONValue] + let hostIdentity: String? var task: Task? var session: LanguageServerRuntimeSession? - init(settings: [String: ToolingJSONValue]) { + init(settings: [String: ToolingJSONValue], hostIdentity: String?) { self.settings = settings + self.hostIdentity = hostIdentity } } @@ -62,6 +65,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { runtime: any LanguageToolRuntimePort, core: any LanguageServerRuntimeCore, processRegistry: (any LanguageServerProcessRegistry)?, + remoteLaunches: MacRemotePluginLaunchPreparer? = nil, stuckSendGrace: Duration = .seconds(2), endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( @@ -75,6 +79,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { self.versionChecker = versionChecker self.runtime = runtime self.core = core + self.remoteLaunches = remoteLaunches self.processRegistry = processRegistry self.endBackendProcesses = endBackendProcesses self.stuckSendGrace = stuckSendGrace @@ -91,14 +96,10 @@ final class MacBuildProviderSessions: BuildProviderRequesting { if let session = try await runningSession(for: target, settings: settings) { return try await session.buildRequest(method: request.method, params: request.params, timeout: seconds) } - switch try launchResolver.resolveBuildProvider( - pluginID: target.pluginID, - buildSystemID: target.buildSystemID, - manifest: target.manifest, - packageURL: target.packageURL, - workspaceRootURL: target.workspaceURL - ) { + let prepared = try await prepareLaunch(target) + switch prepared.launch { case .languageServer(let languageID): + await prepared.owner?.release() guard let sessions = languageSessions?() else { throw UnavailableError(message: String( localized: "The language server this build system uses is not available. Open a file of the language and try again." @@ -113,7 +114,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { timeout: seconds ) case .process(let process): - let session = try await startSession(for: target, process: process) + let session = try await startSession(for: target, process: process, owner: prepared.owner) try Task.checkCancellation() return try await session.buildRequest(method: request.method, params: request.params, timeout: seconds) } @@ -144,20 +145,16 @@ final class MacBuildProviderSessions: BuildProviderRequesting { try Task.checkCancellation() let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) if let session = try await runningSession(for: target, settings: settings) { return session } - switch try launchResolver.resolveBuildProvider( - pluginID: target.pluginID, - buildSystemID: target.buildSystemID, - manifest: target.manifest, - packageURL: target.packageURL, - workspaceRootURL: target.workspaceURL - ) { + let prepared = try await prepareLaunch(target) + switch prepared.launch { case .languageServer: + await prepared.owner?.release() throw PluginFunctionFailure( code: "plugin.function.unavailable", message: L10n.string("This build system's provider runs in a shared language server, which serves no plugin functions.") ) case .process(let process): - let session = try await startSession(for: target, process: process) + let session = try await startSession(for: target, process: process, owner: prepared.owner) try Task.checkCancellation() return session } @@ -191,7 +188,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { settings: [String: ToolingJSONValue] ) async throws -> LanguageServerRuntimeSession? { guard let entry = running[target], let task = entry.task else { return nil } - if entry.settings != settings { + if entry.settings != settings || entry.hostIdentity != remoteLaunches?.identity(target.workspaceURL) { stop(entry, for: target) return nil } @@ -206,22 +203,33 @@ final class MacBuildProviderSessions: BuildProviderRequesting { private func startSession( for target: BuildProviderTarget, - process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess + process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, + owner: MacRemotePluginLaunchPreparer.Owner? ) async throws -> LanguageServerRuntimeSession { // Another request may have started the process while this one // resolved the launch. - if let session = try await runningSession(for: target, settings: process.settings) { return session } - try Task.checkCancellation() - let entry = Running(settings: process.settings) + do { + if let session = try await runningSession(for: target, settings: process.settings) { + await owner?.release() + return session + } + try Task.checkCancellation() + if let owner, !owner.isCurrent() { throw CancellationError() } + } catch { await owner?.release(); throw error } + let entry = Running(settings: process.settings, hostIdentity: remoteLaunches?.identity(target.workspaceURL)) running[target] = entry // The start is shared by every request that arrives meanwhile, so no // single caller's cancellation cancels it; a failed start clears the // entry itself. let task = Task { @MainActor [weak self] () throws -> LanguageServerRuntimeSession in - guard let self else { throw CancellationError() } + guard let self else { + await owner?.release() + throw CancellationError() + } do { - return try await self.launch(process, for: target, entry: entry) + return try await self.launch(process, for: target, entry: entry, owner: owner) } catch { + if entry.session == nil { await owner?.release() } if self.running[target] === entry { self.running[target] = nil } throw error } @@ -269,15 +277,16 @@ final class MacBuildProviderSessions: BuildProviderRequesting { private func launch( _ process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, for target: BuildProviderTarget, - entry: Running + entry: Running, + owner: MacRemotePluginLaunchPreparer.Owner? ) async throws -> LanguageServerRuntimeSession { var (executableURL, environment) = try await resolveExecutable(process, workspaceURL: target.workspaceURL) try Task.checkCancellation() - guard running[target] === entry else { throw CancellationError() } + guard running[target] === entry, owner?.isCurrent() != false else { throw CancellationError() } // Every process the backend starts inherits this token, so what it // leaves behind can be ended when the backend is gone. let backendToken = UUID().uuidString - environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + if owner == nil { environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } } let session = LanguageServerRuntimeSession( providerID: "buildProvider:\(target.pluginID):\(target.buildSystemID)", executableURL: executableURL, @@ -285,17 +294,21 @@ final class MacBuildProviderSessions: BuildProviderRequesting { environment: environment, initializationOptions: process.initializationOptions, initializeTimeout: 60, - core: core, + core: owner?.core ?? core, processRegistry: processRegistry, moduleID: .buildSystemsExtension(target.pluginID), pluginID: target.pluginID ) let endBackendProcesses = endBackendProcesses session.stuckSendGrace = stuckSendGrace - session.terminateProcess = { endBackendProcesses(backendToken, true) } - session.onReleased = { endBackendProcesses(backendToken, false) } + if let owner { session.releaseResources = owner.release } + else { + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + } entry.session = session - try await waitUntilReady(session, rootURL: target.workspaceURL) + do { try await waitUntilReady(session, rootURL: target.workspaceURL) } + catch { session.stop(); throw error } // A queued ready event can race a stop while initialize is suspended. try Task.checkCancellation() guard running[target] === entry else { throw CancellationError() } @@ -351,7 +364,10 @@ final class MacBuildProviderSessions: BuildProviderRequesting { var requirement: LanguageServerExecutableVersionRequirement? switch process.executable { case .file(let url): - return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + if remoteLaunches?.identity(workspaceURL) == nil { + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + } + invocation = BuildInvocation(executables: [.setting(id: "pluginPackage", path: url.path)], runtimeDependencies: runtimes) case .commands(let commands): invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) case .runtime(let tool, let versionRequirement): @@ -380,6 +396,14 @@ final class MacBuildProviderSessions: BuildProviderRequesting { return (resolved.executableURL, resolved.environment) } + private func prepareLaunch(_ target: BuildProviderTarget) async throws -> ( + launch: MacPluginLanguageServerLaunchResolver.BuildProviderLaunch, owner: MacRemotePluginLaunchPreparer.Owner? + ) { + if let prepared = try await remoteLaunches?.prepareBuild(target) { return (prepared.launch, prepared.owner) } + return (try launchResolver.resolveBuildProvider(pluginID: target.pluginID, buildSystemID: target.buildSystemID, + manifest: target.manifest, packageURL: target.packageURL, workspaceRootURL: target.workspaceURL), nil) + } + private static func seconds(_ duration: Duration) -> TimeInterval { let components = duration.components return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift index d3d5e9021b..9dccbb191c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift @@ -18,14 +18,22 @@ final class MacBuildToolResolver: BuildToolResolving { private let runtime: ProjectRuntimeService private let processRunner: any ProcessRunner private let fileStorage: any FileStorage + private let registry: RemoteWorkspaceRegistry? + private let remoteFiles: (any RemoteBuildFileProbing)? + private let remoteProcesses: (any RemoteProcessRunning)? /// Probe output by executable, arguments, and modification date, so a /// repeated run does not start the same probe again. private var probeOutputs: [String: String] = [:] - init(runtime: ProjectRuntimeService, processRunner: any ProcessRunner, fileStorage: any FileStorage) { + init(runtime: ProjectRuntimeService, processRunner: any ProcessRunner, fileStorage: any FileStorage, + registry: RemoteWorkspaceRegistry? = nil, remoteFiles: (any RemoteBuildFileProbing)? = nil, + remoteProcesses: (any RemoteProcessRunning)? = nil) { self.runtime = runtime self.processRunner = processRunner self.fileStorage = fileStorage + self.registry = registry + self.remoteFiles = remoteFiles + self.remoteProcesses = remoteProcesses } func resolve( @@ -34,6 +42,11 @@ final class MacBuildToolResolver: BuildToolResolving { workspaceURL: URL, checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck ) async throws -> ResolvedBuildTool { + if registry?.contains(workspaceURL) == true { + await runtime.ensureRuntimesDiscovered() + try Task.checkCancellation() + guard runtime.projectURL == workspaceURL.standardizedFileURL else { throw CancellationError() } + } var environment = runtime.processEnvironment() var runtimeExecutables: [String: URL] = [:] for dependency in invocation.runtimeDependencies { @@ -47,7 +60,8 @@ final class MacBuildToolResolver: BuildToolResolving { } var problems: [String] = [] - let resolver = candidateResolver(runtimeExecutables) + let resolver = try await preparedResolver(runtimeExecutables, candidates: invocation.executables, + workspaceURL: workspaceURL, environment: environment) for candidate in invocation.executables { switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { case .skip: continue @@ -63,7 +77,7 @@ final class MacBuildToolResolver: BuildToolResolving { } do { if let tool = try await accept(choice.url, invocation: invocation, environment: selectedEnvironment, - checkVersion: checkVersion, problems: &problems) { return tool } + workspaceURL: workspaceURL, checkVersion: checkVersion, problems: &problems) { return tool } } catch is CancellationError { throw CancellationError() } catch { throw BuildToolResolutionError(error.localizedDescription, terminal: choice.terminal) } if choice.terminal { throw BuildToolResolutionError(Self.unavailableMessage(displayName, problems: problems), terminal: true) } @@ -76,6 +90,7 @@ final class MacBuildToolResolver: BuildToolResolving { /// Project import needs the installation path for global settings, but /// must not run build code or a version probe merely by opening a project. func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? { + guard registry?.contains(workspaceURL) != true else { return nil } let environment = runtime.processEnvironment() let resolver = candidateResolver([:]) for candidate in invocation.executables { @@ -89,6 +104,75 @@ final class MacBuildToolResolver: BuildToolResolving { return nil } + func prepareLanguageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) async throws -> URL? { + guard registry?.contains(workspaceURL) == true else { + return languageImportExecutable(invocation, workspaceURL: workspaceURL) + } + try Task.checkCancellation() + guard runtime.projectURL == workspaceURL.standardizedFileURL else { throw CancellationError() } + let environment = runtime.processEnvironment() + let resolver = try await preparedResolver([:], candidates: invocation.executables, + workspaceURL: workspaceURL, environment: environment) + for candidate in invocation.executables { + switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, + environmentInputs: invocation.environmentInputs) { + case .skip: continue + case .terminalFailure: return nil + case .resolved(let choices): return choices.first?.url + } + } + return nil + } + + private func preparedResolver( + _ runtimes: [String: URL], candidates: [ExecutableCandidate], workspaceURL: URL, + environment: [String: String] + ) async throws -> ExecutableCandidateResolver { + guard let binding = registry?.resolve(workspaceURL)?.binding else { return candidateResolver(runtimes) } + guard let remoteFiles else { throw CocoaError(.featureUnsupported) } + var paths = Set() + var commands: [String: [URL]] = [:] + for candidate in candidates { + switch candidate { + case .projectFile(let path, let requires): + paths.insert(workspaceURL.appendingPathComponent(path).standardizedFileURL) + requires.forEach { paths.insert(workspaceURL.appendingPathComponent($0).standardizedFileURL) } + case .environment(let variable, let path): + if let base = environment[variable], base.hasPrefix("/") { + paths.insert(URL(fileURLWithPath: base).appendingPathComponent(path).standardizedFileURL) + } + case .command(let command): + let found = commandCandidates(command) + commands[command] = found + paths.formUnion(found) + case .runtime(let id): if let url = runtimes[id] { paths.insert(url) } + case .setting(_, let path): if let path, path.hasPrefix("/") { paths.insert(URL(fileURLWithPath: path)) } + } + } + let requested = paths.sorted { $0.path < $1.path }.map { url in + let root = binding.root.path + let path = url.path == root ? binding.remoteRoot : url.path.hasPrefix(root + "/") + ? binding.remoteRoot + url.path.dropFirst(root.count) : url.path + return (url, path) + } + let files: [URL: RemoteBuildFileMetadata] = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { + var files: [URL: RemoteBuildFileMetadata] = [:] + for (url, path) in requested { + if let metadata = try remoteFiles.buildFileMetadata(path, session: binding.sessionID) { + files[url] = metadata + } + } + return files + }) + } + } + try Task.checkCancellation() + return ExecutableCandidateResolver(fileExists: { files[$0] != nil }, + isExecutable: { files[$0]?.isExecutable == true }, commands: { commands[$0] ?? [] }, runtimes: { runtimes[$0] }) + } + private func candidateResolver(_ runtimes: [String: URL]) -> ExecutableCandidateResolver { ExecutableCandidateResolver(fileExists: { [fileStorage] in fileStorage.fileExists(at: $0) }, isExecutable: { [fileStorage] in fileStorage.metadata(for: $0)?.isDirectory == false && fileStorage.isExecutable(at: $0) }, @@ -101,13 +185,14 @@ final class MacBuildToolResolver: BuildToolResolving { _ url: URL, invocation: BuildInvocation, environment: [String: String], + workspaceURL: URL, checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck, problems: inout [String] ) async throws -> ResolvedBuildTool? { guard let arguments = invocation.versionArguments, let minimum = invocation.minimumVersion else { return ResolvedBuildTool(executableURL: url, environment: environment, executesProjectCode: false, version: nil) } - let output = await probe(url, arguments: arguments, environment: environment) + let output = try await probe(url, arguments: arguments, environment: environment, workspaceURL: workspaceURL) let check = try checkVersion(nil, output) guard check.satisfied else { problems.append(Self.tooOldMessage(url.path, found: check.version, minimum: minimum)) @@ -131,7 +216,8 @@ final class MacBuildToolResolver: BuildToolResolving { let sources: [ExecutableCandidate] = dependency.hostManaged ? (dependency.id == "java" ? [.runtime("java")] : [.command(dependency.id)]) : dependency.executables - let resolver = candidateResolver(javaHome.map { ["java": $0.appendingPathComponent("bin/java")] } ?? [:]) + let resolver = try await preparedResolver(javaHome.map { ["java": $0.appendingPathComponent("bin/java")] } ?? [:], + candidates: sources, workspaceURL: workspaceURL, environment: environment) var problems: [String] = [] for source in sources { switch resolver.resolve(source, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { @@ -149,7 +235,7 @@ final class MacBuildToolResolver: BuildToolResolving { selectedEnvironment["PATH"] = path.isEmpty ? directory : directory + ":" + path if !choice.executesProjectCode, let minimum = dependency.minimumVersion, let arguments = dependency.versionArguments { - let output = await probe(choice.url, arguments: arguments, environment: selectedEnvironment) + let output = try await probe(choice.url, arguments: arguments, environment: selectedEnvironment, workspaceURL: workspaceURL) let check: BuildVersionCheck do { check = try checkVersion(dependency.id, output) } catch { throw BuildToolResolutionError(error.localizedDescription, terminal: choice.terminal) } @@ -173,7 +259,18 @@ final class MacBuildToolResolver: BuildToolResolving { .map(\.executableURL) } - private func probe(_ url: URL, arguments: [String], environment: [String: String]) async -> String { + private func probe(_ url: URL, arguments: [String], environment: [String: String], workspaceURL: URL) async throws -> String { + if let registry, registry.contains(workspaceURL) { + guard let remoteProcesses else { throw CocoaError(.featureUnsupported) } + let runner = MacRemoteBuildCommandRunner(local: MacBuildCommandRunner(), registry: registry, host: remoteProcesses) + let captured = RemoteBuildProbeOutput() + let result = try await runner.run(BuildCommandRequest(executableURL: url, arguments: arguments, + workingDirectory: workspaceURL, environment: environment, timeout: .milliseconds(Self.probeTimeoutMilliseconds))) { + captured.append($0) + } + guard case .exited(0) = result.termination else { return "" } + return try captured.value() + } let modified = fileStorage.metadata(for: url)?.modificationDate?.timeIntervalSince1970 ?? 0 let key = ([url.path, String(modified)] + arguments).joined(separator: "\u{1F}") if let output = probeOutputs[key] { return output } @@ -202,6 +299,29 @@ final class MacBuildToolResolver: BuildToolResolving { } } +private final class RemoteBuildProbeOutput: @unchecked Sendable { + private let lock = NSLock() + private var output = "" + private var overflowed = false + func append(_ text: String) { + lock.withLock { + guard !overflowed else { return } + guard output.utf8.count + text.utf8.count <= 262_144 else { + overflowed = true + output = "" + return + } + output += text + } + } + func value() throws -> String { + try lock.withLock { + guard !overflowed else { throw CocoaError(.fileReadTooLarge) } + return output + } + } +} + /// Stored option values. They follow the build-system ID, as `build.detect` /// takes them, so a plugin update keeps the user's choices. @MainActor diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 779f4d190d..bf15e1d6b3 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -14,12 +14,21 @@ import LitheTerminalModule import LitheWorkspaceModule private struct MacDirectoryWatcherFactory: DirectoryWatcherFactory { + let registry: RemoteWorkspaceRegistry + let transport: any RemoteWorkspaceTransport + func make( configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void ) -> any DirectoryChangeSource { - MacDirectoryWatcher( + if let binding = registry.resolve(configuration.workspaceRoot)?.binding { + return RemoteDirectoryObservation( + configuration: configuration, visibilityRules: visibilityRules, + binding: binding, registry: registry, transport: transport, onChange: onChange + ) + } + return MacDirectoryWatcher( configuration: configuration, visibilityRules: visibilityRules, onChange: onChange @@ -89,8 +98,10 @@ final class MacServiceContainer { fields: ["operation_id": event.operationId, "duration_ms": event.durationMilliseconds.map(String.init) ?? "", "exit_code": event.exitCode.map(String.init) ?? "", "error_code": event.error?.code ?? ""]) } + let remoteWorkspaces = RemoteWorkspaceRegistry() let rustCore = RustCoreBridge(gitPreferences: settings.gitExecutionPreferences, - gitExecutionJournal: gitExecutionJournal) + gitExecutionJournal: gitExecutionJournal, + remoteWorkspaces: remoteWorkspaces) let mavenRepositoryURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".m2/repository", isDirectory: true) let gradleRepositoryURL = FileManager.default.homeDirectoryForCurrentUser @@ -112,7 +123,6 @@ final class MacServiceContainer { let debugSteppingFilterStore = MacDebugSteppingFilterStore(store: store) // Remote workspaces open under an empty placeholder root; both file // ports route every URL below such a root to the connected host. - let remoteWorkspaces = RemoteWorkspaceRegistry() let remoteOperations = RustRemoteConnectionOperations(core: rustCore) // Core owns the OpenSSH trees; closing them at exit also stops the // remote servers they carry. @@ -178,6 +188,23 @@ final class MacServiceContainer { packageStore: pluginPackageStore, validateUI: { _ = try WorkbenchModuleUIComposition.registry(for: $0) } ).load(recoveryStore: moduleStore) + let pluginMarketplaceEvaluator = RustPluginMarketplaceOperations(core: rustCore) + let pluginMarketplaceTransport: any MacPluginMarketplaceTransport + #if DEBUG + pluginMarketplaceTransport = MacPluginMarketplaceFixtureTransport.fromEnvironment() + ?? MacURLSessionPluginMarketplaceTransport() + #else + pluginMarketplaceTransport = MacURLSessionPluginMarketplaceTransport() + #endif + let remotePluginPackages = MacRemotePluginPackageDeployer( + installed: pluginStartup.registeredPlugins, + makeStore: { architecture in + MacPluginPackageStore(rootURL: fileStorage.applicationSupportDirectory().appendingPathComponent("Lithe/Plugins"), + validator: declarativePluginOperations, architecture: architecture) + }, + evaluator: pluginMarketplaceEvaluator, transport: pluginMarketplaceTransport, + host: remoteOperations, temporaryDirectory: fileStorage.temporaryDirectory() + ) let bundledLanguageManifests = BundledLanguagePluginCatalog.manifests let moduleRegistry = ModuleRegistry( runtime: moduleRuntime, @@ -220,7 +247,20 @@ final class MacServiceContainer { preferredAppearanceIsDark: { settings.themePreference.preferredIsDark } ) }, - shellDiscovery: { MacTerminalTransport.availableShells() } + workspaceTerminalFactory: { root in + let renderer = MacTerminalTransport( + scrollbackLines: settings.terminalScrollbackLines, + preferredAppearanceIsDark: { settings.themePreference.preferredIsDark } + ) + guard let resolved = remoteWorkspaces.resolve(root) else { return renderer } + return MacRemoteTerminalTransport(renderer: renderer, binding: resolved.binding, + transport: remoteOperations) + }, + shellDiscovery: { MacTerminalTransport.availableShells() }, + workspaceShellDiscovery: { root in + guard let resolved = remoteWorkspaces.resolve(root) else { return nil } + return try await MacRemoteShellDiscovery.discover(session: resolved.binding.sessionID, host: remoteOperations) + } ) }) } catch { @@ -229,7 +269,13 @@ final class MacServiceContainer { let runtimeService = ProjectRuntimeService( runtimeLocator: MacRuntimeLocator(), store: store, - toolDiscovery: MacRuntimeToolDiscovery() + toolDiscovery: MacRuntimeToolDiscovery(), + javaSelector: rustCore, + workspaceRuntime: { root in + guard let binding = remoteWorkspaces.binding(forRoot: root) else { return nil } + let locator = MacRemoteRuntimeLocator(binding: binding, host: remoteOperations) + return (locator, locator) + } ) let languageServerCacheDirectory = fileStorage.cacheDirectory() .appendingPathComponent("Lithe/language-servers", isDirectory: true) @@ -253,23 +299,29 @@ final class MacServiceContainer { // Provider processes of plugin build systems, one per plugin, build // system, and workspace. Created eagerly so the language sessions // can be connected; nothing starts until a model or dependency load. + let remotePluginLaunches = MacRemotePluginLaunchPreparer(registry: remoteWorkspaces, packages: remotePluginPackages, + host: remoteOperations, planner: declarativePluginOperations, resolver: pluginLaunchResolver, core: rustCore) + let providerTools = MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage, + registry: remoteWorkspaces, remoteFiles: remoteOperations, remoteProcesses: remoteOperations) let buildProviderSessions = MacBuildProviderSessions( launchResolver: pluginLaunchResolver, - tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + tools: providerTools, versionChecker: declarativePluginOperations, runtime: runtimeService, core: rustCore, - processRegistry: processRegistry + processRegistry: processRegistry, + remoteLaunches: remotePluginLaunches ) // Provider processes of plugin VCS providers, one per plugin, VCS, // and workspace; one starts on the first request for a detected root. let vcsProviderSessions = MacVcsProviderSessions( launchResolver: pluginLaunchResolver, - tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + tools: providerTools, versionChecker: declarativePluginOperations, runtime: runtimeService, core: rustCore, - processRegistry: processRegistry + processRegistry: processRegistry, + remoteLaunches: remotePluginLaunches ) // Plugin backends, one per plugin for the whole application: the // first project window creates them, and every window registers the @@ -362,6 +414,13 @@ final class MacServiceContainer { try pluginLaunchResolver.resolve(source, workspaceRootURL: rootURL) }, languageServerCacheDirectory: languageServerCacheDirectory, + workspaceSessionFactory: { descriptor, root in + LanguageServerRuntimeSession(providerID: descriptor.launchSource?.languageID ?? "remote", + executableURL: root, arguments: [], environment: [:], prepareProcess: { workspace in + try await remotePluginLaunches.prepareLanguage(descriptor, workspaceURL: workspace, + tools: providerTools, versionChecker: declarativePluginOperations) + }, core: rustCore, processRegistry: processRegistry, pluginID: descriptor.launchSource?.pluginID) + }, processRegistry: processRegistry ) let runtimes = languagePackDefinitions.packs @@ -386,12 +445,14 @@ final class MacServiceContainer { .contains(providerID) }, workspaceFingerprintProvider: { descriptor, workspaceRootURL in - try languageServerHostIntegrations.workspaceFingerprint( + if remoteWorkspaces.contains(workspaceRootURL) { return nil } + return try languageServerHostIntegrations.workspaceFingerprint( for: descriptor, workspaceRootURL: workspaceRootURL ) }, workspaceStateResetter: { descriptor, workspaceRootURL, fingerprint in + guard !remoteWorkspaces.contains(workspaceRootURL) else { throw CocoaError(.featureUnsupported) } try languageServerHostIntegrations.resetWorkspaceState( for: descriptor, workspaceRootURL: workspaceRootURL, @@ -399,7 +460,8 @@ final class MacServiceContainer { ) }, workspaceStateCleaner: { descriptor, workspaceRootURL, fingerprint in - try languageServerHostIntegrations.pruneWorkspaceState( + if remoteWorkspaces.contains(workspaceRootURL) { return 0 } + return try languageServerHostIntegrations.pruneWorkspaceState( for: descriptor, workspaceRootURL: workspaceRootURL, fingerprint: fingerprint @@ -436,14 +498,19 @@ final class MacServiceContainer { tools: MacBuildToolResolver( runtime: runtimeService, processRunner: processRunner, - fileStorage: fileStorage + fileStorage: fileStorage, + registry: remoteWorkspaces, + remoteFiles: remoteOperations, + remoteProcesses: remoteOperations ), optionsStore: MacBuildOptionsStore(store: store), - stateLocator: MacBuildSystemStateLocator( + stateLocator: MacRemoteBuildStateLocator(local: MacBuildSystemStateLocator( directories: MacPluginStateDirectories(fileStorage: fileStorage), fileStorage: fileStorage - ), - runner: MacBuildCommandRunner(processRegistry: processRegistry), + ), registry: remoteWorkspaces, host: remoteOperations, packages: remotePluginPackages), + runner: MacRemoteBuildCommandRunner( + local: MacBuildCommandRunner(processRegistry: processRegistry), + registry: remoteWorkspaces, host: remoteOperations), providers: buildProviderSessions, settings: { pluginSettings.values(forPlugin: $0) }, sources: { @@ -701,7 +768,6 @@ final class MacServiceContainer { chooseFile: MacPluginWebFileDialog.choose ) pluginWebPageLifecycle.register(pluginWebPages) - let pluginMarketplaceEvaluator = RustPluginMarketplaceOperations(core: rustCore) let pluginManager = MacPluginManager( packageStore: pluginPackageStore, moduleRuntime: moduleRuntime, @@ -741,13 +807,6 @@ final class MacServiceContainer { ) } ) - let pluginMarketplaceTransport: any MacPluginMarketplaceTransport - #if DEBUG - pluginMarketplaceTransport = MacPluginMarketplaceFixtureTransport.fromEnvironment() - ?? MacURLSessionPluginMarketplaceTransport() - #else - pluginMarketplaceTransport = MacURLSessionPluginMarketplaceTransport() - #endif let pluginMarketplace = MacPluginMarketplaceService( evaluator: pluginMarketplaceEvaluator, packageStore: pluginPackageStore, @@ -827,7 +886,7 @@ final class MacServiceContainer { directoryMarkStore: directoryMarkStore, workbenchLayoutStore: WorkbenchLayoutStore(store: store), workbenchBackgroundPlatform: MacWorkbenchBackgroundPlatform(store: store), - directoryWatcherFactory: MacDirectoryWatcherFactory(), + directoryWatcherFactory: MacDirectoryWatcherFactory(registry: remoteWorkspaces, transport: remoteTransport), platformUI: platformUI, shortcutDetectorFactory: MacShortcutDetectorFactory(), debugPerformanceSampler: MacDebugPerformanceSampler(processRegistry: processRegistry), @@ -853,7 +912,7 @@ final class MacServiceContainer { mappingStore: MacVcsRootMappingStore(store: store), readFile: { url in try? fileStorage.readData(from: url, options: .mappedIfSafe) }, fileExists: { url in fileStorage.fileExists(at: url) }, - metadataWatcherFactory: MacDirectoryWatcherFactory(), + metadataWatcherFactory: MacDirectoryWatcherFactory(registry: remoteWorkspaces, transport: remoteTransport), sources: { // Enablement is read on every detection, so turning a // plugin off takes its roots away at once. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift index 4e6875731b..d440ca7958 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift @@ -132,6 +132,14 @@ struct MacPluginLanguageServerLaunchResolver { return values } + func globalSettingValues(forPlugin pluginID: String) -> [String: ToolingJSONValue] { + settings.values(forPlugin: pluginID) + } + + func projectSettingValues(forPlugin pluginID: String, workspaceRootURL: URL) -> [String: ToolingJSONValue] { + settings.projectValues(forPlugin: pluginID, projectRoot: workspaceRootURL) + } + static var hostArchitecture: String { #if arch(arm64) "arm64" diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index fe61630a0f..4ea0b3ec05 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -88,7 +88,7 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { } } -struct InstalledPluginPackage: Equatable { +struct InstalledPluginPackage: Equatable, Sendable { let descriptor: DeclarativePluginDescriptor let installation: PluginInstallationRecord let packageURL: URL @@ -127,6 +127,7 @@ final class MacPluginPackageStore { /// Expands build provider launches so installation can check the files /// they point at. A package that declares a provider is refused without it. private let launchPlanner: (any DeclarativeLanguageServerLaunchPlanning)? + private let architecture: String private let verifier: any PluginPackageSignatureVerifying private let fileManager: FileManager private let encoder: JSONEncoder @@ -140,6 +141,7 @@ final class MacPluginPackageStore { hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion, validator: any DeclarativePluginManifestValidating, launchPlanner: (any DeclarativeLanguageServerLaunchPlanning)? = nil, + architecture: String = MacPluginLanguageServerLaunchResolver.hostArchitecture, verifier: any PluginPackageSignatureVerifying = MacOfficialPluginSignatureVerifier(), fileManager: FileManager = .default ) { @@ -150,6 +152,7 @@ final class MacPluginPackageStore { self.hostVersion = hostVersion self.validator = validator self.launchPlanner = launchPlanner ?? validator as? any DeclarativeLanguageServerLaunchPlanning + self.architecture = architecture self.verifier = verifier self.fileManager = fileManager encoder = JSONEncoder() @@ -349,16 +352,20 @@ final class MacPluginPackageStore { /// A preparation worker owns its decoder and never reads or writes installed /// records. Immutable validation policy is shared with the installation store. - func makePreparationStoreFactory() -> @Sendable () -> MacPluginPackageStore { + func makePreparationStoreFactory(architecture: String? = nil) -> @Sendable () -> MacPluginPackageStore { let rootURL = rootURL let hostVersion = hostVersion let validator = validator let verifier = verifier + let launchPlanner = launchPlanner + let architecture = architecture ?? self.architecture return { MacPluginPackageStore( rootURL: rootURL, hostVersion: hostVersion, validator: validator, + launchPlanner: launchPlanner, + architecture: architecture, verifier: verifier, fileManager: FileManager() ) @@ -747,7 +754,7 @@ final class MacPluginPackageStore { pluginDirectory: root, stateDirectory: unused.appendingPathComponent("state", isDirectory: true), workspaceStateDirectory: nil, - architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + architecture: architecture, settings: [:] ) ) @@ -781,7 +788,7 @@ final class MacPluginPackageStore { pluginDirectory: root, stateDirectory: unused.appendingPathComponent("state", isDirectory: true), workspaceStateDirectory: unused.appendingPathComponent("workspace", isDirectory: true), - architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + architecture: architecture, settings: [:] ) ) diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift new file mode 100644 index 0000000000..1966e088d8 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift @@ -0,0 +1,85 @@ +import Foundation +import LitheCoreContracts + +/// Keeps build orchestration and decoders intact, selecting process I/O from +/// the command's working directory rather than the currently displayed project. +struct MacRemoteBuildCommandRunner: BuildCommandRunning { + let local: any BuildCommandRunning + let registry: RemoteWorkspaceRegistry + let host: any RemoteProcessRunning + + func run( + _ request: BuildCommandRequest, + onOutput: @escaping @Sendable (String) -> Void + ) async throws -> BuildCommandResult { + guard let resolved = registry.resolve(request.workingDirectory) else { + return try await local.run(request, onOutput: onOutput) + } + let executable = registry.resolve(request.executableURL) + if let executable, executable.binding.sessionID != resolved.binding.sessionID { + throw CocoaError(.featureUnsupported) + } + let capture = RemoteBuildOutput(capturesStandardOutput: request.capturesStandardOutput, onOutput: onOutput) + defer { capture.finish() } + do { + let result = try await host.runProcess( + RemoteProcessRequest(argv: [executable?.remotePath ?? request.executableURL.path] + request.arguments, + cwd: resolved.remotePath, env: request.environment), + session: resolved.binding.sessionID, timeout: request.timeout, + onEvent: capture.receive + ) + capture.finish() + let status = result.exitCode ?? (128 + (result.signal ?? 1)) + return BuildCommandResult(termination: .exited(status), standardOutput: capture.standardOutput) + } catch let failure as RemoteProbeFailure where failure.code == "timed_out" { + return BuildCommandResult(termination: .timedOut, standardOutput: capture.standardOutput) + } + } +} + +private final class RemoteBuildOutput: @unchecked Sendable { + private let lock = NSLock() + private let captures: Bool + private let onOutput: @Sendable (String) -> Void + private var stdout = Data() + private var overflowed = false + private var stdoutDecoder = StreamingUTF8Decoder() + private var stderrDecoder = StreamingUTF8Decoder() + private var finished = false + + init(capturesStandardOutput: Bool, onOutput: @escaping @Sendable (String) -> Void) { + captures = capturesStandardOutput + self.onOutput = onOutput + } + + func receive(_ event: RemoteProcessEvent) { + lock.withLock { + guard !finished else { return } + if case .output(let bytes, let standardError) = event { + if captures && !standardError { + guard !overflowed else { return } + if stdout.count + bytes.count > MacBuildCommandRunner.standardOutputLimit { + overflowed = true + stdout.removeAll() + } else { stdout.append(bytes) } + } else { + let text = standardError ? stderrDecoder.decode(bytes) : stdoutDecoder.decode(bytes) + if !text.isEmpty { onOutput(text) } + } + } + } + } + + func finish() { + lock.withLock { + guard !finished else { return } + finished = true + let text = stdoutDecoder.finish() + stderrDecoder.finish() + if !text.isEmpty { onOutput(text) } + } + } + + var standardOutput: String? { + lock.withLock { captures && !overflowed ? String(data: stdout, encoding: .utf8) : nil } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift new file mode 100644 index 0000000000..df10f5f1f9 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift @@ -0,0 +1,68 @@ +import Foundation +import LitheCoreContracts + +/// Keeps run resources on the workspace's host; cleanup captures the original +/// session so reconnecting the workspace cannot redirect an old run's deletion. +struct MacRemoteBuildStateLocator: BuildSystemStateLocating { + let local: any BuildSystemStateLocating + let registry: RemoteWorkspaceRegistry + let host: any RemoteBuildStateAccess + var packages: (any RemotePluginPackageDeploying)? = nil + + func workspaceStateDirectory(pluginID: String, buildSystemID: String, workspaceURL: URL) throws -> URL { + guard !registry.contains(workspaceURL) else { throw CocoaError(.featureUnsupported) } + return try local.workspaceStateDirectory(pluginID: pluginID, buildSystemID: buildSystemID, workspaceURL: workspaceURL) + } + + func allocateOutputFile(in directory: URL) throws -> URL { + try local.allocateOutputFile(in: directory) + } + + func removeOutputFile(_ url: URL) { local.removeOutputFile(url) } + + func prepareRun(pluginID: String, buildSystemID: String, packageURL: URL?, + workspaceURL: URL, allocatesOutputFile: Bool) async throws -> BuildSystemRunResources { + guard let resolved = registry.resolve(workspaceURL) else { + return try await local.prepareRun(pluginID: pluginID, buildSystemID: buildSystemID, packageURL: packageURL, + workspaceURL: workspaceURL, allocatesOutputFile: allocatesOutputFile) + } + try Task.checkCancellation() + let session = resolved.binding.sessionID + let deployed: RemoteDeployedPluginPackage? + if let packageURL, let packages, + try packages.buildRequiresPackage(pluginID: pluginID, buildSystemID: buildSystemID, packageURL: packageURL) { + deployed = try await packages.deploy(pluginID: pluginID, packageURL: packageURL, session: session) + } else { deployed = nil } + let directory: String + do { + try Task.checkCancellation() + guard registry.binding(forRoot: resolved.binding.root)?.sessionID == session else { throw CancellationError() } + directory = try await Self.worker { + try host.prepareBuildState(pluginID: pluginID, buildSystemID: buildSystemID, + workspacePath: resolved.remotePath, session: session) + } + try Task.checkCancellation() + guard directory.hasPrefix("/"), !directory.contains("\0") else { throw CocoaError(.fileReadCorruptFile) } + } catch { + await deployed?.release() + throw error + } + let state = URL(fileURLWithPath: directory, isDirectory: true) + let output = allocatesOutputFile ? state.appendingPathComponent(UUID().uuidString + ".json") : nil + return BuildSystemRunResources(pluginDirectory: deployed?.packageURL, workspaceStateDirectory: state, outputFile: output) { + if let output { + do { try await Self.worker { try host.removeBuildOutputFile(output.path, session: session) } } + catch { NSLog("Remote build output cleanup failed (%@)", String(describing: type(of: error))) } + } + await deployed?.release() + } + } + + private static func worker(_ operation: @escaping @Sendable () throws -> Value) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { try operation() }) + } + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift new file mode 100644 index 0000000000..ffc1216945 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift @@ -0,0 +1,198 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule + +/// Resolves provider declarations only after the approved target package is +/// present. Every returned launch and cleanup retains the original connection. +@MainActor +final class MacRemotePluginLaunchPreparer { + struct Build { + let launch: MacPluginLanguageServerLaunchResolver.BuildProviderLaunch + let owner: Owner + } + + struct Vcs { + let launch: MacPluginLanguageServerLaunchResolver.VcsProviderProcess + let owner: Owner + } + + struct Owner { + let core: any LanguageServerRuntimeCore + let release: @Sendable () async -> Void + let isCurrent: @MainActor () -> Bool + } + + let registry: RemoteWorkspaceRegistry + let packages: any RemotePluginPackageDeploying + let host: any RemotePluginFileAccess + let planner: any DeclarativeLanguageServerLaunchPlanning + let resolver: MacPluginLanguageServerLaunchResolver + let core: RustCoreBridge + + init(registry: RemoteWorkspaceRegistry, packages: any RemotePluginPackageDeploying, + host: any RemotePluginFileAccess, planner: any DeclarativeLanguageServerLaunchPlanning, + resolver: MacPluginLanguageServerLaunchResolver, core: RustCoreBridge) { + self.registry = registry + self.packages = packages + self.host = host + self.planner = planner + self.resolver = resolver + self.core = core + } + + func identity(_ root: URL) -> String? { registry.resolve(root)?.binding.sessionID } + + func prepareLanguage(_ descriptor: LanguageServerLaunchDescriptor, workspaceURL: URL, + tools: any BuildToolResolving, versionChecker: any LanguageServerRuntimeVersionChecking) async throws -> PreparedLanguageServerProcess { + guard let binding = registry.resolve(workspaceURL)?.binding, let source = descriptor.launchSource else { + throw CocoaError(.featureUnsupported) + } + let prepared = try await prepare(pluginID: source.pluginID, packageURL: source.packageURL, + workspaceURL: workspaceURL, binding: binding, scope: "language-servers", provider: source.languageID) { manifest, request in + try self.planner.languageServerLaunchPlan(manifest: manifest, languageID: source.languageID, request: request) + } + do { + // Native integrations own additional filesystem and protocol data. + // Do not accidentally start them as a generic stdio server. + guard prepared.plan.hostIntegration == nil else { throw CocoaError(.featureUnsupported) } + let process = try process(prepared.plan, root: prepared.packageURL, + settings: resolver.settingValues(forPlugin: source.pluginID, workspaceRootURL: workspaceURL), + executesProjectCode: false, mayUseNetwork: true) + let dependencies = process.runtimeTools.map { BuildInvocation.RuntimeDependency(id: $0, hostManaged: true) } + let candidates: [ExecutableCandidate] + var runtimes = dependencies + var requirement: LanguageServerExecutableVersionRequirement? + switch process.executable { + case .file(let url): candidates = [.setting(id: "pluginPackage", path: url.path)] + case .commands(let commands): candidates = commands.map { .command($0) } + case .runtime(let tool, let version): + candidates = [.runtime(tool)] + requirement = version + runtimes.insert(.init(id: tool, hostManaged: true, minimumVersion: version?.minimumVersion, + versionArguments: version?.probeArguments), at: 0) + } + let version = requirement + let resolved = try await tools.resolve(BuildInvocation(executables: candidates, runtimeDependencies: runtimes), + displayName: source.languageID, workspaceURL: workspaceURL, checkVersion: { _, output in + guard let version else { return .init(satisfied: true, version: nil) } + let checked = try versionChecker.checkRuntimeVersion(version, output: output) + return .init(satisfied: checked.satisfied, version: checked.version) + }) + try Task.checkCancellation() + guard prepared.owner.isCurrent() else { throw CancellationError() } + var environment = resolved.environment + environment.merge(descriptor.environment) { _, configured in configured } + return PreparedLanguageServerProcess(executableURL: resolved.executableURL, environment: environment, + launch: ResolvedLanguageServerLaunch(arguments: prepared.plan.arguments, + initializationOptions: prepared.plan.initializationOptions, workspaceConfiguration: prepared.plan.workspaceConfiguration), + core: prepared.owner.core, release: prepared.owner.release) + } catch { await prepared.owner.release(); throw error } + } + + func prepareBuild(_ target: BuildProviderTarget) async throws -> Build? { + guard let binding = registry.resolve(target.workspaceURL)?.binding else { return nil } + let prepared = try await prepare(pluginID: target.pluginID, packageURL: target.packageURL, + workspaceURL: target.workspaceURL, binding: binding, scope: "build-systems", provider: target.buildSystemID) { manifest, request in + try self.planner.buildProviderLaunchPlan(manifest: manifest, buildSystemID: target.buildSystemID, request: request) + } + do { + guard let provider = prepared.plan.buildProvider, provider.buildSystemID == target.buildSystemID else { + throw CocoaError(.fileReadCorruptFile) + } + let launch: MacPluginLanguageServerLaunchResolver.BuildProviderLaunch + if let language = provider.languageServer { launch = .languageServer(language) } + else { + launch = .process(try process(prepared.plan, root: prepared.packageURL, + settings: resolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL), + executesProjectCode: provider.executesProjectCode, mayUseNetwork: provider.mayUseNetwork)) + } + return Build(launch: launch, owner: prepared.owner) + } catch { await prepared.owner.release(); throw error } + } + + func prepareVcs(_ target: VcsProviderTarget) async throws -> Vcs? { + guard let binding = registry.resolve(target.workspaceURL)?.binding else { return nil } + let prepared = try await prepare(pluginID: target.pluginID, packageURL: target.packageURL, + workspaceURL: target.workspaceURL, binding: binding, scope: "vcs-providers", provider: target.vcs) { manifest, request in + try self.planner.vcsProviderLaunchPlan(manifest: manifest, vcs: target.vcs, request: request) + } + do { + guard let provider = prepared.plan.vcsProvider, provider.vcs == target.vcs else { throw CocoaError(.fileReadCorruptFile) } + let process = try process(prepared.plan, root: prepared.packageURL, + settings: resolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL), + executesProjectCode: false, mayUseNetwork: provider.mayUseNetwork) + return Vcs(launch: .init(process: process, vcs: provider.vcs, toolCommands: provider.toolCommands, + toolValidationArguments: provider.toolValidationArguments), owner: prepared.owner) + } catch { await prepared.owner.release(); throw error } + } + + private struct Prepared { + let plan: DeclarativeLanguageServerLaunchPlan + let packageURL: URL + let owner: Owner + } + + private func prepare(pluginID: String, packageURL: URL, workspaceURL: URL, binding: RemoteWorkspaceBinding, + scope: String, provider: String, + plan: (Data, DeclarativeLanguageServerLaunchRequest) throws -> DeclarativeLanguageServerLaunchPlan) async throws -> Prepared { + let deployed = try await packages.deploy(pluginID: pluginID, packageURL: packageURL, session: binding.sessionID) + do { + try Task.checkCancellation() + let facts = try await host.pluginHost(session: binding.sessionID) + guard let home = facts.homeDirectory, home.hasPrefix("/"), !home.contains("\0") else { throw CocoaError(.fileReadCorruptFile) } + let state = URL(fileURLWithPath: home).appendingPathComponent(".lithe-server/state/plugins") + .appendingPathComponent(digest(pluginID)).appendingPathComponent(scope).appendingPathComponent(digest(provider)) + let workspaceState = state.appendingPathComponent("workspaces").appendingPathComponent(digest(binding.remoteRoot)) + let request = DeclarativeLanguageServerLaunchRequest(pluginDirectory: deployed.packageURL, stateDirectory: state, + workspaceStateDirectory: workspaceState, architecture: deployed.architecture, + settings: resolver.globalSettingValues(forPlugin: pluginID), + projectSettings: resolver.projectSettingValues(forPlugin: pluginID, workspaceRootURL: workspaceURL)) + let expanded = try plan(deployed.manifest, request) + for path in expanded.requiredPackagePaths { + let value = try await host.pluginRequest("fs.stat", params: .object([ + "path": .string(deployed.packageURL.appendingPathComponent(path).path)]), session: binding.sessionID) + guard case .object(let metadata) = value, + metadata["kind"] == .string("file") || metadata["kind"] == .string("directory") else { + throw CocoaError(.fileReadCorruptFile) + } + } + for directory in expanded.requiredDirectories { + let url = directory == .state ? state : workspaceState + _ = try await host.pluginRequest("fs.createDir", params: .object([ + "path": .string(url.path), "recursive": .bool(true), "mode": .integer(0o700)]), session: binding.sessionID) + } + try Task.checkCancellation() + guard identity(workspaceURL) == binding.sessionID else { throw CancellationError() } + let fixed = RemoteWorkspaceRegistry() + fixed.register(binding) + var fixedCore = core + fixedCore.remoteWorkspaces = fixed + return Prepared(plan: expanded, packageURL: deployed.packageURL, + owner: Owner(core: fixedCore, release: deployed.release, isCurrent: { + self.identity(workspaceURL) == binding.sessionID + })) + } catch { await deployed.release(); throw error } + } + + private func process(_ plan: DeclarativeLanguageServerLaunchPlan, root: URL, + settings: [String: ToolingJSONValue], executesProjectCode: Bool, + mayUseNetwork: Bool) throws -> MacPluginLanguageServerLaunchResolver.BuildProviderProcess { + let executable: MacPluginLanguageServerLaunchResolver.BuildProviderProcess.Executable + switch plan.executable { + case .packaged(let path)?: executable = .file(root.appendingPathComponent(path)) + case .systemCommand(let commands)?: executable = .commands(commands) + case .runtime(let tool)?: + executable = plan.packagedRuntimePath.map { .file(root.appendingPathComponent($0)) } + ?? .runtime(tool, versionRequirement: plan.executableVersionRequirement) + case nil: throw CocoaError(.fileReadCorruptFile) + } + return .init(executable: executable, arguments: plan.arguments, initializationOptions: plan.initializationOptions, + runtimeTools: plan.runtimeRequirements.map(\.tool), executesProjectCode: executesProjectCode, + mayUseNetwork: mayUseNetwork, settings: settings) + } + + private func digest(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift new file mode 100644 index 0000000000..8a2cd006a3 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift @@ -0,0 +1,246 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import LithePluginPackageSigning + +/// Deploys a fresh private snapshot of an approved package for one execution. +/// The host receives files only: no package manager, Core or archive extractor +/// is added to the small daemon. The caller owns the returned release action. +struct MacRemotePluginPackageDeployer: RemotePluginPackageDeploying { + let installed: [InstalledPluginPackage] + let makeStore: @Sendable (String) -> MacPluginPackageStore + let evaluator: any PluginMarketplaceEvaluating + let transport: any MacPluginMarketplaceTransport + let host: any RemotePluginFileAccess + let temporaryDirectory: URL + + func buildRequiresPackage(pluginID: String, buildSystemID: String, packageURL: URL) throws -> Bool { + guard let original = installed.first(where: { + $0.descriptor.id.rawValue == pluginID && $0.packageURL.standardizedFileURL == packageURL.standardizedFileURL + }), let manifest = try JSONSerialization.jsonObject(with: original.descriptor.normalizedManifest) as? [String: Any], + let builds = manifest["buildSystems"] as? [[String: Any]], + let build = builds.first(where: { $0["id"] as? String == buildSystemID }) else { + throw failure("plugin.remote.unapproved", L10n.string("The remote plugin package is not part of the approved installation.")) + } + func referencesPackage(_ value: Any) -> Bool { + if let text = value as? String { return text.contains("${pluginDir}") } + if let values = value as? [Any] { return values.contains(where: referencesPackage) } + if let object = value as? [String: Any] { return object.values.contains(where: referencesPackage) } + return false + } + return referencesPackage(build) + } + + func deploy(pluginID: String, packageURL: URL, session: String) async throws -> RemoteDeployedPluginPackage { + guard let original = installed.first(where: { + $0.descriptor.id.rawValue == pluginID && $0.packageURL.standardizedFileURL == packageURL.standardizedFileURL + }) else { throw failure("plugin.remote.unapproved", L10n.string("The remote plugin package is not part of the approved installation.")) } + let facts = try await host.pluginHost(session: session) + guard let platform = facts.platform, facts.features.contains("upload") else { + throw failure("plugin.remote.unsupported", L10n.string("This host cannot receive a compatible plugin package. Update the remote server.")) + } + // Revalidate the original approval before using its source or programs. + _ = try await worker { + try makeStore(MacPluginLanguageServerLaunchResolver.hostArchitecture).verifyCandidatePackage( + at: packageURL, origin: original.installation.origin, + marketplace: original.installation.marketplace, disk: original.installation.disk) + } + let local: LocalSnapshot + if platform == MacPluginMarketplaceService.currentPlatform { + local = try await snapshot(original, packageURL: packageURL, architecture: facts.templateArchitecture) + } else { + guard let provenance = original.installation.marketplace, + let indexURL = URL(string: try evaluator.resolveSource(provenance.repository).indexURL) else { + throw incompatible(pluginID, platform) + } + let kind: PluginMarketplaceSourceKind = original.installation.origin == .community ? .thirdParty : .official + let indexData = try await transport.data(from: indexURL, maximumBytes: 5 * 1_048_576) + let index = try evaluator.validateIndex(indexData, sourceKind: kind, platform: platform) + guard let entry = index.plugins.first(where: { $0.id == pluginID && $0.repository == provenance.repository }), + let download = entry.availability.download, + download.version == original.manifest.version.description, + let url = URL(string: download.downloadURL) else { throw incompatible(pluginID, platform) } + let downloaded = try await worker { + try await MacPluginPackageDownloader(transport: transport, temporaryDirectory: temporaryDirectory).download( + archiveURL: url, expectedSHA256: download.sha256, pluginID: original.descriptor.id) + } + do { + local = try await worker { + let store = makeStore(facts.templateArchitecture) + let descriptor = try store.inspectPackage(at: downloaded.packageURL) + let review = try evaluator.installReview(manifest: descriptor.normalizedManifest, expectedID: pluginID, + expectedVersion: original.manifest.version.description, + approvedPrograms: try provenance.approvedPrograms.map { try PluginProgram(canonicalJSON: $0) }) + // A target variant must not acquire unapproved permissions or + // change the declaration consumed by the current module graph. + guard review.newPrograms.isEmpty, + descriptor.normalizedManifest == original.descriptor.normalizedManifest else { + throw failure("plugin.remote.approvalRequired", L10n.string("The remote plugin package changes approved commands or permissions. Install and approve that version first.")) + } + let remoteProvenance = PluginMarketplaceProvenance(sourceID: provenance.sourceID, + sourceName: provenance.sourceName, repository: provenance.repository, + archiveSHA256: download.sha256, + contentDigest: try PluginPackageSignature.contentDigest(packageAt: downloaded.packageURL), + approvedPrograms: provenance.approvedPrograms) + _ = try store.verifyCandidatePackage(at: downloaded.packageURL, origin: original.installation.origin, + marketplace: remoteProvenance) + let snapshot = try LocalSnapshot.read(at: downloaded.packageURL, descriptor: descriptor) + _ = try store.verifyCandidatePackage(at: downloaded.packageURL, origin: original.installation.origin, + marketplace: remoteProvenance) + return snapshot + } + } catch { + await removeLocal(downloaded.temporaryDirectory) + throw error + } + // Files are streamed from this extraction tree below. Its release + // must occur only after the last upload or an upload failure. + return try await transfer(local, session: session, facts: facts, + localCleanup: downloaded.temporaryDirectory) + } + return try await transfer(local, session: session, facts: facts, localCleanup: nil) + } + + private func snapshot(_ original: InstalledPluginPackage, packageURL: URL, architecture: String) async throws -> LocalSnapshot { + try await worker { + let store = makeStore(architecture) + let descriptor = try store.verifyCandidatePackage(at: packageURL, + origin: original.installation.origin, marketplace: original.installation.marketplace, disk: original.installation.disk) + let snapshot = try LocalSnapshot.read(at: packageURL, descriptor: descriptor) + _ = try store.verifyCandidatePackage(at: packageURL, origin: original.installation.origin, + marketplace: original.installation.marketplace, disk: original.installation.disk) + return snapshot + } + } + + private func transfer(_ snapshot: LocalSnapshot, session: String, facts: RemotePluginHost, + localCleanup: URL?) async throws -> RemoteDeployedPluginPackage { + var remoteRoot: String? + do { + guard let home = facts.homeDirectory, home.hasPrefix("/"), !home.contains("\0"), + URL(fileURLWithPath: home).standardizedFileURL.path == home else { + throw CocoaError(.fileReadCorruptFile) + } + let parent = home + "/.lithe-server/plugin-runs" + _ = try await host.pluginRequest("fs.createDir", params: .object([ + "path": .string(parent), "recursive": .bool(true), "mode": .integer(0o700)]), session: session) + for directory in [home + "/.lithe-server", parent] { + let metadata = try await host.pluginRequest("fs.stat", params: .object(["path": .string(directory)]), session: session) + guard case .object(let value) = metadata, value["kind"] == .string("directory"), + case .integer(let mode) = value["mode"], mode & 0o077 == 0 else { + throw CocoaError(.fileWriteNoPermission) + } + } + let resolved = try await host.pluginRequest("fs.realPath", params: .object(["path": .string(parent)]), session: session) + guard case .object(let value) = resolved, case .string(let canonical) = value["path"] else { throw CocoaError(.fileReadCorruptFile) } + // Keep the exact owned path before allocation: cancellation after + // the host creates it still has a known cleanup destination. + let root = canonical + "/" + UUID().uuidString + remoteRoot = root + _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(root), "mode": .integer(0o700)]), session: session) + let package = root + "/package" + _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(package), "mode": .integer(0o700)]), session: session) + for entry in snapshot.entries { + try Task.checkCancellation() + let target = package + "/" + entry.path + if entry.directory { + _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(target), "mode": .integer(0o700)]), session: session) + } else { + try await host.uploadPluginFile(snapshot.root.appendingPathComponent(entry.path), to: target, + sha256: entry.sha256, mode: entry.executable ? 0o500 : 0o400, session: session) + } + } + // Detect package replacement or mutation during transfer, including + // mode changes not covered by the publisher's content signature. + let after = try await worker { try LocalSnapshot.read(at: snapshot.root, descriptor: snapshot.descriptor) } + guard snapshot.entries == after.entries else { throw CocoaError(.fileReadCorruptFile) } + if let localCleanup { await removeLocal(localCleanup) } + return RemoteDeployedPluginPackage(packageURL: URL(fileURLWithPath: package, isDirectory: true), + manifest: snapshot.descriptor.normalizedManifest, architecture: facts.templateArchitecture) { + await release(root, session: session) + } + } catch { + if let remoteRoot { await release(remoteRoot, session: session) } + if let localCleanup { await removeLocal(localCleanup) } + throw error + } + } + + private func release(_ root: String, session: String) async { + // A cancelled build still needs an uncancelled cleanup operation on its + // original host. Directories stay private and owner-writable for removal; + // runtime state is always outside this snapshot and files are read-only. + let cleanup = Task.detached { + do { + do { + _ = try await host.pluginRequest("fs.remove", params: .object(["path": .string(root), "recursive": .bool(true)]), session: session) + } catch let error as RemoteProbeFailure where error.code == "workspace_not_found" { + // Cancellation may have won before the allocation. + } + } catch { NSLog("Remote plugin snapshot cleanup failed (%@)", String(describing: type(of: error))) } + } + await cleanup.value + } + + private func removeLocal(_ directory: URL) async { + await Task.detached { + do { try FileManager().removeItem(at: directory) } + catch { NSLog("Remote plugin extraction cleanup failed (%@)", String(describing: type(of: error))) } + }.value + } + + private func worker(_ operation: @escaping @Sendable () async throws -> Value) async throws -> Value { + let task = Task.detached(priority: .utility, operation: operation) + return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + } + + private func failure(_ code: String, _ message: String) -> DeclarativePluginRejection { + DeclarativePluginRejection(code: code, message: message) + } + + private func incompatible(_ id: String, _ platform: String) -> DeclarativePluginRejection { + failure("plugin.remote.platformUnavailable", L10n.string("Plugin \(id) has no approved package for \(platform). Install a compatible plugin version.")) + } +} + +private struct LocalSnapshot: Sendable { + struct Entry: Equatable, Sendable { + let path: String + let directory: Bool + let executable: Bool + let sha256: String + } + let root: URL + let descriptor: DeclarativePluginDescriptor + let entries: [Entry] + + static func read(at root: URL, descriptor: DeclarativePluginDescriptor) throws -> Self { + let manager = FileManager() + let root = root.standardizedFileURL + var entries: [Entry] = [] + var total: Int64 = 0 + func visit(_ directory: URL, relative: String) throws { + for url in try manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).sorted(by: { $0.path < $1.path }) { + try Task.checkCancellation() + guard entries.count < 50_000 else { throw CocoaError(.fileReadTooLarge) } + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey, .isDirectoryKey, .fileSizeKey]) + guard values.isSymbolicLink != true, values.isDirectory == true || values.isRegularFile == true else { + throw CocoaError(.fileReadUnsupportedScheme) + } + // Directory enumeration may canonicalize /var to /private/var. + // Build relative names from components, never string offsets. + let path = relative + url.lastPathComponent + guard !path.isEmpty, !path.contains("\0"), path.utf8.count <= 4096 else { throw CocoaError(.fileReadInvalidFileName) } + total += Int64(values.fileSize ?? 0) + guard total <= 2 * 1024 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } + let directory = values.isDirectory == true + entries.append(Entry(path: path, directory: directory, executable: !directory && manager.isExecutableFile(atPath: url.path), + sha256: directory ? "" : try MacPluginPackageDownloader.sha256(of: url))) + if directory { try visit(url, relative: path + "/") } + } + } + try visit(root, relative: "") + return Self(root: root, descriptor: descriptor, entries: entries) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift new file mode 100644 index 0000000000..0336d68363 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift @@ -0,0 +1,239 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// A workspace-owned host snapshot. UI accessors never perform network I/O or +/// inspect this Mac; only discover runs host probes on the service's worker. +final class MacRemoteRuntimeLocator: RuntimeLocator, RuntimeToolDiscovery, @unchecked Sendable { + private struct Snapshot { + var environment: [String: String] = [:] + var executables: Set = [] + var java: [JavaRuntimeCandidate] = [] + var maven: [MavenRuntimeCandidate] = [] + var pathJava: String? + var failure: String? + var requirements: ToolingJSONValue? + } + private let binding: RemoteWorkspaceBinding + private let host: any RemoteRuntimeProbing + private let lock = NSLock() + private var snapshot = Snapshot() + private var configured: [String] = [] + private var generation: UInt64 = 0 + + init(binding: RemoteWorkspaceBinding, host: any RemoteRuntimeProbing) { + self.binding = binding + self.host = host + } + + var discoveryFailure: String? { lock.withLock { snapshot.failure } } + func environment() -> [String: String] { lock.withLock { snapshot.environment } } + func configureProbePaths(_ paths: [String]) { + lock.withLock { + guard configured != paths else { return } + configured = paths + generation &+= 1 + snapshot = Snapshot() + } + } + func invalidateProbeCache() { lock.withLock { generation &+= 1; snapshot = Snapshot() } } + + func selectJavaRuntime(candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result? { + let current = lock.withLock { snapshot } + guard let requirements = current.requirements else { + return .failure(.init(code: "runtime_discovery_failed", + message: current.failure ?? L10n.string("Could not discover runtimes on the remote host. Check the connection and refresh."), details: nil)) + } + return RustCoreBridge().selectJavaRuntime(requirements: requirements, candidates: candidates, fallbackID: fallbackID) + } + + func normalizedPath(_ path: String, relativeTo root: URL?) -> String { + var value = path.trimmingCharacters(in: .whitespacesAndNewlines) + if value == "~" || value.hasPrefix("~/") { + guard let home = environment()["HOME"], home.hasPrefix("/") else { return value } + value = home + value.dropFirst() + } + if !value.hasPrefix("/"), let root { value = root.appendingPathComponent(value).path } + let local = binding.root.path + if value == local { value = binding.remoteRoot } + else if value.hasPrefix(local + "/") { value = binding.remoteRoot + value.dropFirst(local.count) } + // NSString.standardizingPath expands ~ with the local account; only + // absolute host paths may reach Foundation's normalization here. + return value.hasPrefix("/") ? URL(fileURLWithPath: value).standardizedFileURL.path : value + } + + func discover() -> RuntimeDiscoveryResult { discover(isCancelled: { false }) } + func discover(isCancelled: @Sendable () -> Bool) -> RuntimeDiscoveryResult { + let (ticket, paths) = lock.withLock { () -> (UInt64, [String]) in + generation &+= 1 + return (generation, configured) + } + var next = Snapshot() + let deadline = ContinuousClock.now.advanced(by: .seconds(60)) + func check() throws { + if isCancelled() || lock.withLock({ generation != ticket }) { throw CancellationError() } + if ContinuousClock.now >= deadline { throw ProbeError.deadline } + } + func optional(_ operation: () throws -> Value) throws -> Value? { + try check() + do { return try operation() } + catch let error as RustCoreBridge.CoreCallError + where error.code == "workspace_not_found" || error.details?.contains("NOT_A_DIRECTORY") == true { return nil } + catch let error as RemoteProbeFailure where error.code == "not_found" { return nil } + } + func executable(_ path: String) throws -> Bool { + let found = try optional { try host.runtimeExecutable(path, session: binding.sessionID) } ?? false + if found { next.executables.insert(path) } + return found + } + func entries(_ path: String) throws -> [RemoteDirectoryEntry] { + let values = try optional { try host.runtimeDirectory(path, session: binding.sessionID) } ?? [] + guard values.count <= 16_384 else { throw ProbeError.limit } + return values + } + func canonical(_ path: String) throws -> String { + try check() + return try host.runtimeRealPath(path, session: binding.sessionID) + } + do { + try check() + next.environment = try host.runtimeEnvironment(session: binding.sessionID) + try check() + next.requirements = try host.runtimeRequirements(binding.remoteRoot, session: binding.sessionID) + guard case .object = next.requirements else { throw ProbeError.invalidRequirements } + if let requirements = next.requirements { + _ = try RustCoreBridge().selectJavaRuntime(requirements: requirements, candidates: [], fallbackID: nil).get() + } + var homes = Set() + if let home = next.environment["JAVA_HOME"], home.hasPrefix("/") { homes.insert(home) } + for path in paths where !path.isEmpty { + // Use the just-read remote HOME, not the previous snapshot. + let expanded = path == "~" || path.hasPrefix("~/") ? (next.environment["HOME"] ?? "~") + path.dropFirst() : path + let resolved = expanded.hasPrefix("/") ? expanded : binding.remoteRoot + "/" + expanded + homes.insert(resolved) + if !(try executable(resolved)) { + for tool in ["mvn", "mvnd"] { + _ = try executable(resolved + "/bin/" + tool) + _ = try executable(resolved + "/" + tool) + } + } + } + let home = next.environment["HOME"] ?? "" + var roots = ["/usr/lib/jvm", "/Library/Java/JavaVirtualMachines"] + if home.hasPrefix("/") { + roots += [home + "/Library/Java/JavaVirtualMachines", + (next.environment["SDKMAN_DIR"] ?? home + "/.sdkman") + "/candidates/java"] + } + for root in roots { + for entry in try entries(root) where entry.isDirectory { + let path = root + "/" + entry.name + homes.insert(root.contains("JavaVirtualMachines") ? path + "/Contents/Home" : path) + } + } + // Login PATH already includes the host's configured version managers. + let searchPath = (next.environment["PATH"] ?? "").split(separator: ":").map(String.init).filter { $0.hasPrefix("/") } + guard searchPath.count <= 64 else { throw ProbeError.limit } + for directory in searchPath { + for entry in try entries(directory) where entry.metadata.kind == "file" || entry.metadata.kind == "symlink" { + let path = directory + "/" + entry.name + if entry.metadata.kind == "symlink" { + guard entry.metadata.symlinkTargetKind == "file" else { continue } + if let mode = entry.metadata.symlinkTargetMode { + guard mode & 0o111 != 0 else { continue } + } else if !(try executable(path)) { continue } + } else if let mode = entry.metadata.mode { + guard mode & 0o111 != 0 else { continue } + } else if !(try executable(path)) { continue } + next.executables.insert(path) + guard next.executables.count <= 65_536 else { throw ProbeError.limit } + if entry.name == "java", next.pathJava == nil { + let resolved = try canonical(path) + if resolved != "/usr/bin/java" { + let home = URL(fileURLWithPath: resolved).deletingLastPathComponent().deletingLastPathComponent().path + next.pathJava = home + homes.insert(home) + } + } + } + } + for relative in ["mvnw", "gradlew"] { _ = try executable(binding.remoteRoot + "/" + relative) } + guard homes.count <= 32 else { throw ProbeError.limit } + var seen = Set() + for home in homes.sorted() where try executable(home + "/bin/java") { + let resolved = try canonical(home) + guard seen.insert(resolved).inserted else { continue } + next.executables.insert(resolved + "/bin/java") + try check() + if let java = MacRuntimeDiscovery.javaRuntime(home: URL(fileURLWithPath: resolved), + output: try host.runtimeVersion(home + "/bin/java", session: binding.sessionID)) { + next.java.append(java) + } + } + var mavenPaths = next.executables.filter { ["mvn", "mvnd"].contains(URL(fileURLWithPath: $0).lastPathComponent) }.sorted() + for key in ["MAVEN_HOME", "M2_HOME", "MVND_HOME"] { + if let home = next.environment[key], home.hasPrefix("/") { + let path = home + "/bin/" + (key == "MVND_HOME" ? "mvnd" : "mvn") + if try executable(path) { mavenPaths.insert(path, at: 0) } + } + } + guard mavenPaths.count <= 16 else { throw ProbeError.limit } + for path in mavenPaths { + try check() + let output = try host.runtimeVersion(path, session: binding.sessionID) + if let version = MacRuntimeDiscovery.mavenVersion(in: output) { + let resolved = try canonical(path) + next.maven.append(MavenRuntimeCandidate(homePath: URL(fileURLWithPath: resolved).deletingLastPathComponent().deletingLastPathComponent().path, + executablePath: path, version: version)) + } + } + next.java.sort { $0.version.localizedStandardCompare($1.version) == .orderedDescending } + next.maven.sort { ($0.executablePath.hasSuffix("/mvnd") ? 1 : 0) < ($1.executablePath.hasSuffix("/mvnd") ? 1 : 0) } + } catch is CancellationError { return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + catch { + // A failed/incomplete host snapshot must never authorize a launch. + next = Snapshot() + next.failure = L10n.string("Could not discover runtimes on the remote host. Check the connection and refresh.") + } + guard !isCancelled() else { return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + let published = lock.withLock { () -> Bool in + guard generation == ticket else { return false } + snapshot = next + return true + } + guard published else { return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + return RuntimeDiscoveryResult(javaRuntimes: next.java, mavenRuntimes: next.maven) + } + + private enum ProbeError: Error { case deadline, limit, invalidRequirements } + func discoverJavaRuntimes() -> [JavaRuntimeCandidate] { lock.withLock { snapshot.java } } + func discoverJavaRuntimes(isCancelled: @Sendable () -> Bool) -> [JavaRuntimeCandidate] { isCancelled() ? [] : discoverJavaRuntimes() } + func validJavaHome(path: String) -> URL? { + let path = normalizedPath(path, relativeTo: binding.root) + return isExecutable(at: URL(fileURLWithPath: path + "/bin/java")) ? URL(fileURLWithPath: path) : nil + } + func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? { + lock.withLock { snapshot.pathJava.map { URL(fileURLWithPath: $0) } } + } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { lock.withLock { snapshot.java.first { $0.homePath == homeURL.path } } } + func isExecutable(at url: URL) -> Bool { lock.withLock { snapshot.executables.contains(normalizedPathWithoutLock(url.path)) } } + private func normalizedPathWithoutLock(_ path: String) -> String { + let root = binding.root.path + return path == root ? binding.remoteRoot : path.hasPrefix(root + "/") ? binding.remoteRoot + path.dropFirst(root.count) : path + } + func systemMavenExecutable() -> URL? { lock.withLock { snapshot.maven.first.map { URL(fileURLWithPath: $0.executablePath) } } } + func mavenExecutable(forHomePath path: String) -> URL? { + let path = normalizedPath(path, relativeTo: binding.root) + return [path, path + "/bin/mvnd", path + "/bin/mvn"].first { isExecutable(at: URL(fileURLWithPath: $0)) }.map { URL(fileURLWithPath: $0) } + } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { lock.withLock { snapshot.maven.first { $0.executablePath == executableURL.path } } } + func candidates(for command: String, projectURL: URL?, environment: [String: String]) -> [RuntimeToolCandidate] { + guard !command.isEmpty, !command.contains("/") else { return [] } + return (environment["PATH"] ?? "").split(separator: ":").map { URL(fileURLWithPath: String($0)).appendingPathComponent(command) } + .filter { isExecutable(at: $0) }.map { RuntimeToolCandidate(command: command, executableURL: $0, source: .path, detail: nil) } + } + func guidance(for command: String, projectURL: URL?, environment: [String: String]) -> RuntimeToolGuidance { + RuntimeToolGuidance(command: command, + summary: L10n.string("The required tool is not available on the remote host."), + recovery: L10n.string("Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes.")) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift new file mode 100644 index 0000000000..e77bb17534 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift @@ -0,0 +1,82 @@ +import Foundation +import LitheModuleAPI + +/// Discovers executable shells using only the selected host's login environment +/// and filesystem. Directory batches avoid one network round trip per candidate. +enum MacRemoteShellDiscovery { + static func discover(session: String, host: any RemoteShellProbing) async throws -> [String] { + let cancellation = Cancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { + try availableShells(session: session, host: host, isCancelled: { cancellation.isCancelled }) + }) + } + } + } onCancel: { cancellation.cancel() } + } + + static func availableShells( + session: String, host: any RemoteShellProbing, isCancelled: () -> Bool + ) throws -> [String] { + let deadline = ContinuousClock.now + .seconds(30) + func check() throws { + if isCancelled() { throw CancellationError() } + if ContinuousClock.now >= deadline { throw DiscoveryError.failed } + } + do { + try check() + let environment = try host.shellEnvironment(session: session) + try check() + let registered = try host.registeredShells(session: session) + let candidates = MacTerminalShellDiscovery.candidates( + environment: environment, registeredShells: registered, searchDefaults: []) + guard candidates.count <= 1_024 else { throw DiscoveryError.failed } + let directories = Set(candidates.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path }) + guard directories.count <= 64 else { throw DiscoveryError.failed } + var executable = Set() + for directory in directories.sorted() { + try check() + let entries: [RemoteDirectoryEntry] + do { entries = try host.runtimeDirectory(directory, session: session) } + catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { continue } + guard entries.count <= 16_384 else { throw DiscoveryError.failed } + let names = Set(candidates.filter { URL(fileURLWithPath: $0).deletingLastPathComponent().path == directory } + .map { URL(fileURLWithPath: $0).lastPathComponent }) + for entry in entries where names.contains(entry.name) { + let path = URL(fileURLWithPath: directory).appendingPathComponent(entry.name).path + let metadata = entry.metadata + if metadata.kind == "file", let mode = metadata.mode, mode & 0o111 != 0 { + executable.insert(path) + } else if metadata.kind == "symlink", metadata.symlinkTargetKind == "file" { + if let mode = metadata.symlinkTargetMode { + if mode & 0o111 != 0 { executable.insert(path) } + } else { + // Older servers report target kind without permissions. + try check() + if try host.runtimeExecutable(path, session: session) { executable.insert(path) } + } + } + } + } + try check() + return candidates.filter { executable.contains($0) } + } catch is CancellationError { throw CancellationError() } + catch { throw DiscoveryError.failed } + } + + private enum DiscoveryError: LocalizedError { + case failed + var errorDescription: String? { + L10n.string("Could not discover shells on the remote host. Check the connection and try again.") + } + } + + private final class Cancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + var isCancelled: Bool { lock.withLock { cancelled } } + func cancel() { lock.withLock { cancelled = true } } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift new file mode 100644 index 0000000000..d162112c70 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift @@ -0,0 +1,312 @@ +import AppKit +import Foundation +import LitheModuleAPI +import LitheTerminalModule +import SwiftTerm + +/// Reuses the native terminal renderer while its PTY lives on the workspace +/// host. No local process or local login environment is used for this surface. +@MainActor +final class MacRemoteTerminalTransport: TerminalTransport { + private let renderer: MacTerminalTransport + private let binding: RemoteWorkspaceBinding + private let transport: any RemoteTerminalConnecting + private var delegate: RemoteTerminalViewDelegate? + private var run: Task? + private var controls: Task? + private var launch: CheckedContinuation? + private var terminalID: UInt64? + private var generation = UUID() + private var queuedInputBytes = 0 + private var pendingSize: (columns: UInt16, rows: UInt16)? + private var resizeQueued = false + private static let maximumQueuedInputBytes = 1024 * 1024 + + private(set) var isRunning = false + private(set) var processID: Int32? + var shellName: String { "Shell" } + var nativeView: AnyObject { renderer.nativeView } + var termProgramVersion: String? { renderer.termProgramVersion } + var reportsLightBackground: Bool? { renderer.reportsLightBackground } + var requiresAsyncStart: Bool { true } + var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onLink: ((String, [String: String]) -> Void)? + + init(renderer: MacTerminalTransport, binding: RemoteWorkspaceBinding, transport: any RemoteTerminalConnecting) { + self.renderer = renderer + self.binding = binding + self.transport = transport + let delegate = RemoteTerminalViewDelegate(view: renderer.view) + self.delegate = delegate + renderer.view.terminalDelegate = delegate + delegate.onInput = { [weak self] data in try? self?.send(data) } + delegate.onResize = { [weak self] columns, rows in self?.resize(columns: columns, rows: rows) } + renderer.onOutput = { [weak self] data in self?.onOutput?(data) } + renderer.onTitle = { [weak self] title in self?.onTitle?(title) } + renderer.onDirectoryUpdate = { [weak self] directory in + guard let self, let directory else { return } + let path = URL(string: directory).flatMap { $0.isFileURL ? $0.path : nil } ?? directory + onDirectoryUpdate?(localPath(forRemotePath: path) ?? binding.root.path) + } + renderer.onLink = { [weak self] link, params in + guard let self else { return } + onLink?(localPath(forRemotePath: link) ?? link, params) + } + } + + func configureInitialSurface(showsWorkbenchBackground: Bool) { + renderer.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + } + + func defaultShellPath() -> String { "Shell" } + func defaultEnvironment() -> [String: String] { [:] } + + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { + throw RemoteTerminalFailure.asyncStartRequired + } + + func startShell(workingDirectory: String, shellPath: String, environment: [String: String]) async throws { + let size = renderer.view.getWindowSize() + let request = RemoteTerminalRequest( + argv: shellPath == defaultShellPath() ? nil : [shellPath] + MacTerminalShellDiscovery.startupArguments(for: shellPath), + cwd: try remoteDirectory(workingDirectory, allowsHostPath: false), + env: environment, columns: max(1, size.ws_col), rows: max(1, size.ws_row) + ) + try await start(request) + } + + func startProcessAsync(_ launch: TerminalProcessLaunch, environment: [String: String]) async throws -> Int32 { + let size = renderer.view.getWindowSize() + let request = RemoteTerminalRequest( + argv: [remotePath(forLocalPath: launch.executablePath)] + launch.arguments, + cwd: try remoteDirectory(launch.workingDirectory, allowsHostPath: true), + env: environment, columns: max(1, size.ws_col), rows: max(1, size.ws_row) + ) + try await start(request) + guard let processID else { throw RemoteTerminalFailure.closedBeforeStart } + return processID + } + + private func start(_ request: RemoteTerminalRequest) async throws { + let previousRun = run + let previousControls = controls + stop() + let stoppedGeneration = generation + await previousRun?.value + await previousControls?.value + // Closing this tab while its predecessor cleans up must not start a + // replacement process after the close has already returned. + guard generation == stoppedGeneration else { throw CancellationError() } + try Task.checkCancellation() + let generation = UUID() + self.generation = generation + let transport = transport + let session = binding.sessionID + renderer.clear() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + launch = continuation + run = Task { [weak self] in + let result = await transport.runTerminal(request, session: session) { [weak self] event in + // The C observer runs on the adapter's blocking worker. + // Waiting for the renderer bounds buffered output and + // propagates backpressure through SSH to the remote PTY. + if Thread.isMainThread { + MainActor.assumeIsolated { self?.receive(event, generation: generation) } + } else { + DispatchQueue.main.sync { + MainActor.assumeIsolated { self?.receive(event, generation: generation) } + } + } + } + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation else { return } + if let launch = self.launch { + self.launch = nil + switch result { + case .success: launch.resume(throwing: RemoteTerminalFailure.closedBeforeStart) + case .failure(let failure): launch.resume(throwing: failure) + } + } + // A failed connection may end without terminal.exit. + // Release the same identity and controls as an explicit stop. + let wasRunning = self.isRunning + self.stop() + if wasRunning { self.onTermination?(nil) } + } + } + } + } onCancel: { + Task { @MainActor [weak self] in + guard let self, self.generation == generation else { return } + stop() + } + } + } + + private func receive(_ event: RemoteTerminalEvent, generation: UUID) { + guard self.generation == generation else { return } + switch event { + case .started(let id, let pid): + terminalID = id + processID = pid + isRunning = true + renderer.view.hasRemoteProcess = true + let continuation = launch + launch = nil + continuation?.resume() + let size = renderer.view.getWindowSize() + resize(columns: Int(size.ws_col), rows: Int(size.ws_row)) + case .output(let data): renderer.view.dataReceived(slice: Array(data)[...]) + case .exited(let code): + guard isRunning else { return } + isRunning = false + processID = nil + terminalID = nil + pendingSize = nil + controls?.cancel() + renderer.view.hasRemoteProcess = false + onTermination?(code) + } + } + + func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 { + throw RemoteTerminalFailure.asyncStartRequired + } + + func send(_ input: Data) throws { + guard !input.isEmpty, isRunning, let id = terminalID else { return } + guard queuedInputBytes + input.count <= Self.maximumQueuedInputBytes else { + throw RemoteTerminalFailure.inputBusy + } + queuedInputBytes += input.count + let transport = transport + let session = binding.sessionID + enqueue(inputBytes: input.count) { + try await transport.writeTerminal(input, id: id, session: session) + } + } + + private func resize(columns: Int, rows: Int) { + guard isRunning else { return } + pendingSize = (UInt16(clamping: max(1, columns)), UInt16(clamping: max(1, rows))) + guard !resizeQueued else { return } + resizeQueued = true + let transport = transport + let session = binding.sessionID + let generation = generation + enqueue { [weak self] in + guard let self else { return } + defer { if self.generation == generation { resizeQueued = false } } + while self.generation == generation, let size = pendingSize, let id = terminalID { + pendingSize = nil + try await transport.resizeTerminal(id: id, columns: size.columns, rows: size.rows, session: session) + } + } + } + + private func enqueue(inputBytes: Int = 0, _ operation: @escaping @MainActor () async throws -> Void) { + let previous = controls + let generation = generation + controls = Task { [weak self] in + await previous?.value + guard !Task.isCancelled, self?.generation == generation else { return } + defer { + if self?.generation == generation { self?.queuedInputBytes -= inputBytes } + } + do { try await operation() } + catch { + guard let self, self.generation == generation else { return } + let wasRunning = isRunning + stop() + if wasRunning { onTermination?(nil) } + } + } + } + + func interrupt() throws { try send(Data([0x03])) } + func focus() { renderer.focus() } + func clear() { renderer.clear() } + func stop() { + generation = UUID() + isRunning = false + processID = nil + terminalID = nil + queuedInputBytes = 0 + pendingSize = nil + resizeQueued = false + renderer.view.hasRemoteProcess = false + run?.cancel() + controls?.cancel() + let continuation = launch + launch = nil + continuation?.resume(throwing: CancellationError()) + } + + func stopAndWait() async { + let run = run + let controls = controls + stop() + await run?.value + await controls?.value + } + + private func localPath(forRemotePath path: String) -> String? { + let root = binding.remoteRoot == "/" ? "" : binding.remoteRoot + guard path == root || path.hasPrefix(root + "/") else { return nil } + return binding.root.path + String(path.dropFirst(root.count)) + } + + private func remotePath(forLocalPath path: String) -> String { + let root = binding.root.standardizedFileURL.path + guard path == root || path.hasPrefix(root + "/") else { return path } + let remote = binding.remoteRoot == "/" ? "" : binding.remoteRoot + let mapped = remote + path.dropFirst(root.count) + return mapped.isEmpty ? "/" : mapped + } + + private func remoteDirectory(_ path: String, allowsHostPath: Bool) throws -> String { + let normalized = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL.path + let root = binding.root.standardizedFileURL.path + guard path.hasPrefix("/"), allowsHostPath || normalized == root || normalized.hasPrefix(root + "/") else { + throw RemoteTerminalFailure.invalidDirectory + } + return remotePath(forLocalPath: normalized) + } + + deinit { run?.cancel(); controls?.cancel() } +} + +/// SwiftTerm's local delegate ignores size changes without a local child. +/// Forward rendering/clipboard behavior to it and route only PTY IO remotely. +@MainActor +private final class RemoteTerminalViewDelegate: @preconcurrency TerminalViewDelegate { + private weak var view: LitheTerminalView? + var onInput: ((Data) -> Void)? + var onResize: ((Int, Int) -> Void)? + init(view: LitheTerminalView) { self.view = view } + func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) { onResize?(newCols, newRows) } + func send(source: SwiftTerm.TerminalView, data: ArraySlice) { onInput?(Data(data)) } + func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) { view?.setTerminalTitle(source: source, title: title) } + func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) { view?.hostCurrentDirectoryUpdate(source: source, directory: directory) } + func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { view?.requestOpenLink(source: source, link: link, params: params) } + func scrolled(source: SwiftTerm.TerminalView, position: Double) { view?.scrolled(source: source, position: position) } + func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) { view?.rangeChanged(source: source, startY: startY, endY: endY) } + func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) { view?.clipboardCopy(source: source, content: content) } + func clipboardRead(source: SwiftTerm.TerminalView) -> Data? { view?.clipboardRead(source: source) } +} + +private enum RemoteTerminalFailure: LocalizedError { + case asyncStartRequired, invalidDirectory, closedBeforeStart, inputBusy + var errorDescription: String? { + switch self { + case .asyncStartRequired: L10n.string("The remote terminal must be started asynchronously.") + case .invalidDirectory: L10n.string("The terminal directory is outside the remote workspace.") + case .closedBeforeStart: L10n.string("The remote terminal closed before it started.") + case .inputBusy: L10n.string("The remote terminal is busy. Wait before sending more input.") + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index 8891731cad..8ac6907d2e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -187,6 +187,11 @@ enum MacRuntimeDiscovery { arguments: ["-version"], dependencies: [home.appendingPathComponent("release")], accepts: { firstCapture(pattern: #"version\s+\"([^\"]+)\""#, in: $0) != nil } ) + return javaRuntime(home: home, output: output) + } + + /// Local and remote probes share the upstream version/banner parser. + static func javaRuntime(home: URL, output: String) -> JavaRuntimeCandidate? { guard let version = firstCapture(pattern: #"version\s+\"([^\"]+)\""#, in: output) else { return nil } diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift index 1582a732bf..c39f9a9e30 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift @@ -36,7 +36,8 @@ enum MacTerminalShellDiscovery { static func candidates( environment: [String: String], - registeredShells: String + registeredShells: String, + searchDefaults: [String]? = nil ) -> [String] { var candidates = [environment["SHELL"]].compactMap { $0 } candidates += registeredShells.split(whereSeparator: \.isNewline).compactMap { line in @@ -45,7 +46,7 @@ enum MacTerminalShellDiscovery { return path.isEmpty ? nil : path } let searchDirectories = (environment["PATH"] ?? "").split(separator: ":").map(String.init) - + standardSearchDirectories + + (searchDefaults ?? standardSearchDirectories) for directory in searchDirectories where directory.hasPrefix("/") { candidates += shellNames.map { URL(fileURLWithPath: directory).appendingPathComponent($0).path } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index a518205334..e6feb9dcb2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -7,6 +7,7 @@ import LitheTerminalModule /// SwiftTerm's default link handler opens URLs in the system. Lithe needs the /// link event so workspace-relative paths can open in its own editor instead. final class LitheTerminalView: LocalProcessTerminalView { + var hasRemoteProcess = false static let focusDidChange = Notification.Name("LitheTerminalView.focusDidChange") var onOpenLink: ((String, [String: String]) -> Void)? var onProcessOutput: ((Data) -> Void)? @@ -219,7 +220,7 @@ final class LitheTerminalView: LocalProcessTerminalView { let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor) guard payload != lastReportedBackgroundPayload else { return } lastReportedBackgroundPayload = payload - guard process.running, + guard process.running || hasRemoteProcess, let notification = MacTerminalBackgroundReporting.changeNotification( for: nativeBackgroundColor, isSubscribed: backgroundChangeNotifications.isSubscribed diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift index edd3616e42..22bcccd95c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift @@ -28,6 +28,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { private let versionChecker: any LanguageServerRuntimeVersionChecking private let runtime: any LanguageToolRuntimePort private let core: any LanguageServerRuntimeCore + private let remoteLaunches: MacRemotePluginLaunchPreparer? private weak var processRegistry: (any LanguageServerProcessRegistry)? /// Ends the processes carrying a backend's token; `immediately` skips the /// graceful stop. See `MacPluginBackendProcesses`. @@ -37,11 +38,13 @@ final class MacVcsProviderSessions: VcsProviderRequesting { /// One process, from its start until it is dropped. private final class Running { let settings: [String: ToolingJSONValue] + let hostIdentity: String? var task: Task? var session: LanguageServerRuntimeSession? - init(settings: [String: ToolingJSONValue]) { + init(settings: [String: ToolingJSONValue], hostIdentity: String?) { self.settings = settings + self.hostIdentity = hostIdentity } } @@ -60,6 +63,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { runtime: any LanguageToolRuntimePort, core: any LanguageServerRuntimeCore, processRegistry: (any LanguageServerProcessRegistry)?, + remoteLaunches: MacRemotePluginLaunchPreparer? = nil, endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( token: token, @@ -72,6 +76,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { self.versionChecker = versionChecker self.runtime = runtime self.core = core + self.remoteLaunches = remoteLaunches self.processRegistry = processRegistry self.endBackendProcesses = endBackendProcesses } @@ -133,7 +138,8 @@ final class MacVcsProviderSessions: VcsProviderRequesting { try Task.checkCancellation() let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) if let entry = running[target], let task = entry.task { - if entry.settings != settings || entry.session.map({ !$0.isRunning }) == true { + if entry.settings != settings || entry.session.map({ !$0.isRunning }) == true + || entry.hostIdentity != remoteLaunches?.identity(target.workspaceURL) { stop(entry, for: target) } else { let started = try await Self.waitForStart(task) @@ -141,7 +147,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { return started } } - let entry = Running(settings: settings) + let entry = Running(settings: settings, hostIdentity: remoteLaunches?.identity(target.workspaceURL)) running[target] = entry // The start is shared by every request that arrives meanwhile, so no // single caller's cancellation cancels it; a failed start clears the @@ -194,21 +200,27 @@ final class MacVcsProviderSessions: VcsProviderRequesting { } private func launch(_ target: VcsProviderTarget, entry: Running) async throws -> Started { - let provider = try launchResolver.resolveVcsProvider( + let prepared = try await remoteLaunches?.prepareVcs(target) + let provider = try prepared?.launch ?? launchResolver.resolveVcsProvider( pluginID: target.pluginID, vcs: target.vcs, manifest: target.manifest, packageURL: target.packageURL, workspaceRootURL: target.workspaceURL ) - var (executableURL, environment) = try await resolveExecutable(provider.process, workspaceURL: target.workspaceURL) - let toolPath = try await resolveTool(provider, workspaceURL: target.workspaceURL) - try Task.checkCancellation() - guard running[target] === entry else { throw CancellationError() } + let executableURL: URL + var environment: [String: String] + let toolPath: String? + do { + (executableURL, environment) = try await resolveExecutable(provider.process, workspaceURL: target.workspaceURL) + toolPath = try await resolveTool(provider, workspaceURL: target.workspaceURL) + try Task.checkCancellation() + guard running[target] === entry, prepared?.owner.isCurrent() != false else { throw CancellationError() } + } catch { await prepared?.owner.release(); throw error } // Every process the backend starts, the VCS client included, inherits // this token, so what it leaves behind can be ended when it is gone. let backendToken = UUID().uuidString - environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + if prepared == nil { environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } } let session = LanguageServerRuntimeSession( providerID: "vcsProvider:\(target.pluginID):\(target.vcs)", executableURL: executableURL, @@ -216,16 +228,20 @@ final class MacVcsProviderSessions: VcsProviderRequesting { environment: environment, initializationOptions: provider.process.initializationOptions, initializeTimeout: 60, - core: core, + core: prepared?.owner.core ?? core, processRegistry: processRegistry, moduleID: .vcsProvidersExtension(target.pluginID), pluginID: target.pluginID ) let endBackendProcesses = endBackendProcesses - session.terminateProcess = { endBackendProcesses(backendToken, true) } - session.onReleased = { endBackendProcesses(backendToken, false) } + if let owner = prepared?.owner { session.releaseResources = owner.release } + else { + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + } entry.session = session - try await waitUntilReady(session, rootURL: target.workspaceURL) + do { try await waitUntilReady(session, rootURL: target.workspaceURL) } + catch { session.stop(); throw error } try Task.checkCancellation() guard running[target] === entry else { throw CancellationError() } // Core publishes the negotiation before the session is ready. A @@ -288,7 +304,10 @@ final class MacVcsProviderSessions: VcsProviderRequesting { var requirement: LanguageServerExecutableVersionRequirement? switch process.executable { case .file(let url): - return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + if remoteLaunches?.identity(workspaceURL) == nil { + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + } + invocation = BuildInvocation(executables: [.setting(id: "pluginPackage", path: url.path)], runtimeDependencies: runtimes) case .commands(let commands): invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) case .runtime(let tool, let versionRequirement): diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 1427bac9c1..90b34554b5 100644 --- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -78,9 +78,13 @@ final class ProjectRuntimeService: ObservableObject { private var runtimeSessionID = UUID() private let javaSelector: any JavaRuntimeSelecting - private let runtimeLocator: any RuntimeLocator + private let localRuntimeLocator: any RuntimeLocator + private var runtimeLocator: any RuntimeLocator private let store: any KeyValueStore - private let toolDiscovery: any RuntimeToolDiscovery + private let localToolDiscovery: any RuntimeToolDiscovery + private var toolDiscovery: any RuntimeToolDiscovery + private let workspaceRuntime: ((URL) -> (any RuntimeLocator, any RuntimeToolDiscovery)?)? + private var usesWorkspaceRuntime = false private var discoveryTask: Task? private var activeDiscoveryID: UUID? private var javaLanguageServerRuntimePreparation: JavaLanguageServerRuntimePreparation = .unprepared @@ -93,12 +97,16 @@ final class ProjectRuntimeService: ObservableObject { runtimeLocator: any RuntimeLocator, store: any KeyValueStore, toolDiscovery: (any RuntimeToolDiscovery)? = nil, - javaSelector: any JavaRuntimeSelecting = RustCoreBridge() + javaSelector: any JavaRuntimeSelecting = RustCoreBridge(), + workspaceRuntime: ((URL) -> (any RuntimeLocator, any RuntimeToolDiscovery)?)? = nil ) { self.javaSelector = javaSelector self.runtimeLocator = runtimeLocator + self.localRuntimeLocator = runtimeLocator self.store = store self.toolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() + self.localToolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() + self.workspaceRuntime = workspaceRuntime } deinit { @@ -111,6 +119,10 @@ final class ProjectRuntimeService: ObservableObject { activeDiscoveryID = nil let normalizedURL = url.standardizedFileURL projectURL = normalizedURL + let selected = workspaceRuntime?(normalizedURL) + usesWorkspaceRuntime = selected != nil + runtimeLocator = selected?.0 ?? localRuntimeLocator + toolDiscovery = selected?.1 ?? localToolDiscovery javaRuntimes = [] mavenRuntimes = [] discoveredProjectURL = nil @@ -127,6 +139,9 @@ final class ProjectRuntimeService: ObservableObject { discoveryTask = nil activeDiscoveryID = nil projectURL = nil + runtimeLocator = localRuntimeLocator + toolDiscovery = localToolDiscovery + usesWorkspaceRuntime = false javaRuntimes = [] mavenRuntimes = [] discoveredProjectURL = nil @@ -153,9 +168,10 @@ final class ProjectRuntimeService: ObservableObject { /// JDKs for the New Project window, which can open without a project. /// Leaves the open project's discovery state untouched. func discoverJavaRuntimesForNewProject() async -> [JavaRuntimeCandidate] { - if hasDiscoveredRuntimes { return javaRuntimes } - let runtimeLocator = runtimeLocator - return await Task.detached(priority: .userInitiated) { runtimeLocator.discoverJavaRuntimes() }.value + let runtimeLocator = localRuntimeLocator + return await Self.backgroundProbe { cancelled in + runtimeLocator.discoverJavaRuntimes(isCancelled: cancelled) + } } func refreshAvailableRuntimes() async { @@ -179,10 +195,9 @@ final class ProjectRuntimeService: ObservableObject { } } let runtimeLocator = runtimeLocator - let result = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .utility).async { - continuation.resume(returning: runtimeLocator.discover()) - } + runtimeLocator.configureProbePaths([settings.javaHomePath, settings.mavenJavaHomePath, settings.mavenExecutableOverride]) + let result = await Self.backgroundProbe { cancelled in + runtimeLocator.discover(isCancelled: cancelled) } guard !Task.isCancelled, projectURL == targetProjectURL, @@ -190,6 +205,11 @@ final class ProjectRuntimeService: ObservableObject { javaRuntimes = result.javaRuntimes mavenRuntimes = result.mavenRuntimes discoveredProjectURL = targetProjectURL + if let message = runtimeLocator.discoveryFailure, let projectURL { + javaEnvironmentReport = JavaEnvironmentReport(status: .probeFailed(message: message), + projectURL: projectURL, javaHomePath: nil, javaExecutablePath: nil) + return + } refreshJavaEnvironmentReport(using: result.javaRuntimes) isDiscovering = false } @@ -235,7 +255,7 @@ final class ProjectRuntimeService: ObservableObject { // fast path. Unknown versions never satisfy a real minimum, so only // an unconstrained result may bypass discovery here. let candidate = AutomaticJavaCandidate(id: environmentHome.path, version: "", priority: 0) - switch javaSelector.selectJavaRuntime(at: projectURL, candidates: [candidate], fallbackID: candidate.id) { + switch selectJavaRuntime(candidates: [candidate], fallbackID: candidate.id) { case .failure(let error): return .unavailable(error.message) case .success(let selection) where selection.warning == nil: return .found(environmentHome, .javaHomeEnvironment) @@ -257,7 +277,7 @@ final class ProjectRuntimeService: ObservableObject { candidates.append(AutomaticJavaCandidate(id: home.path, version: "", priority: 0)) } let fallback = environmentHome?.path ?? candidates.first?.id - switch javaSelector.selectJavaRuntime(at: projectURL, candidates: candidates, fallbackID: fallback) { + switch selectJavaRuntime(candidates: candidates, fallbackID: fallback) { case .failure(let error): return .unavailable(error.message) case .success(let selection): guard let id = selection.id else { return .notFound } @@ -268,6 +288,13 @@ final class ProjectRuntimeService: ObservableObject { } } + private func selectJavaRuntime( + candidates: [AutomaticJavaCandidate], fallbackID: String? + ) -> Result { + runtimeLocator.selectJavaRuntime(candidates: candidates, fallbackID: fallbackID) + ?? javaSelector.selectJavaRuntime(at: projectURL, candidates: candidates, fallbackID: fallbackID) + } + func javaExecutableURL(overridePath: String? = nil) -> URL? { javaHomeURL(overridePath: overridePath)?.appendingPathComponent("bin/java") } @@ -513,9 +540,7 @@ final class ProjectRuntimeService: ObservableObject { } func executableURL(at path: String) -> URL? { - let normalized = (path as NSString) - .expandingTildeInPath - .trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = normalizedOverridePath(path) guard !normalized.isEmpty else { return nil } let url = URL(fileURLWithPath: normalized).standardizedFileURL return runtimeLocator.isExecutable(at: url) ? url : nil @@ -534,9 +559,7 @@ final class ProjectRuntimeService: ObservableObject { func chooseMavenExecutable(at rootURL: URL, overridePath: String? = nil) -> RuntimeChoice { let configured = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !configured.isEmpty { - let resolved = configured.hasPrefix("/") - ? URL(fileURLWithPath: configured) - : rootURL.appendingPathComponent(configured) + let resolved = URL(fileURLWithPath: runtimeLocator.normalizedPath(configured, relativeTo: rootURL)) let standardized = resolved.standardizedFileURL if runtimeLocator.isExecutable(at: standardized) { return .found(standardized, .configured) @@ -686,7 +709,14 @@ final class ProjectRuntimeService: ObservableObject { func updateSettings(_ settings: ProjectRuntimeSettings) { self.settings = settings + runtimeLocator.configureProbePaths([settings.javaHomePath, settings.mavenJavaHomePath, settings.mavenExecutableOverride]) persistSettings() + if usesWorkspaceRuntime { + discoveredProjectURL = nil + discoveryTask?.cancel() + discoveryTask = Task { [weak self] in await self?.performRuntimeRefresh() } + return + } if !javaRuntimes.isEmpty { refreshJavaEnvironmentReport(using: javaRuntimes) } @@ -791,14 +821,11 @@ final class ProjectRuntimeService: ObservableObject { private func normalizedOverridePath(_ path: String) -> String { let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedPath.isEmpty else { return "" } - let normalized = normalizedPath(trimmedPath) - guard !(normalized as NSString).isAbsolutePath, - let projectURL else { return normalized } - return projectURL.appendingPathComponent(normalized).standardizedFileURL.path + return runtimeLocator.normalizedPath(trimmedPath, relativeTo: projectURL) } private func normalizedPath(_ path: String) -> String { - ((path as NSString).expandingTildeInPath as NSString).standardizingPath + runtimeLocator.normalizedPath(path, relativeTo: nil) } } diff --git a/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift new file mode 100644 index 0000000000..b4453ba8dd --- /dev/null +++ b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift @@ -0,0 +1,133 @@ +import Foundation +import LitheCoreContracts + +/// Owns host subscriptions independently of the UI executor. A generation +/// prevents late events from a cancelled or replaced subscription refreshing +/// the next workspace. +final class RemoteDirectoryObservation: DirectoryChangeSource, @unchecked Sendable { + private let lock = NSRecursiveLock() + private let configuration: DirectoryWatchConfiguration + private let visibilityRules: FileVisibilityRules + private let binding: RemoteWorkspaceBinding + private let registry: RemoteWorkspaceRegistry + private let transport: any RemoteWorkspaceTransport + private let onChange: @Sendable (DirectoryChangeBatch) -> Void + private var tasks: [Task] = [] + private var generation = UUID() + + init( + configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, + binding: RemoteWorkspaceBinding, registry: RemoteWorkspaceRegistry, + transport: any RemoteWorkspaceTransport, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) { + self.configuration = configuration + self.visibilityRules = visibilityRules + self.binding = binding + self.registry = registry + self.transport = transport + self.onChange = onChange + } + + func start() { + lock.withLock { + stop() + let current = generation + for root in configuration.physicalRoots { + // Git common directories may live outside the workspace. The + // Core Git engine already returns those as absolute host paths. + let remoteRoot = registry.resolve(root)?.remotePath ?? root.path + let transport = transport + let session = binding.sessionID + tasks.append(Task { [weak self] in + do { + try await transport.watch(root: remoteRoot, session: session) { [weak self] event in + self?.receive(event, generation: current) + } + } catch { + guard !Task.isCancelled else { return } + NSLog("Remote directory watch ended: %@", String(describing: type(of: error))) + self?.receive(.invalidated(rootRemoved: false), generation: current) + } + }) + } + } + } + + func stop() { + lock.withLock { + generation = UUID() + tasks.forEach { $0.cancel() } + tasks.removeAll() + } + } + + private func receive(_ event: RemoteWatchEvent, generation: UUID) { + lock.withLock { + guard self.generation == generation else { return } + var batch = DirectoryChangeBatch() + switch event { + case .started: + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + case .invalidated(let rootRemoved): + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + batch.watchRootsChanged = rootRemoved + case .changed(let paths, let overflow): + batch.requiresFullRescan = overflow + batch.gitStateMayHaveChanged = overflow + var workspacePaths = Set() + for path in paths { + let hostRoot = binding.remoteRoot.hasSuffix("/") ? binding.remoteRoot : binding.remoteRoot + "/" + let url: URL + if path == binding.remoteRoot { url = binding.root } + else if path.hasPrefix(hostRoot) { + url = binding.root.appendingPathComponent(String(path.dropFirst(hostRoot.count))).standardizedFileURL + } else { url = URL(fileURLWithPath: path).standardizedFileURL } + if configuration.revalidatesIgnoredDirectory(url, isStructuralChange: true) { + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + } + if configuration.isIgnored(url) { continue } + if configuration.isGitContextPointer(url) { + batch.gitStateMayHaveChanged = true + batch.watchRootsChanged = true + } else if configuration.containsGitMetadataPath(url) { + batch.gitStateMayHaveChanged = true + } else if configuration.containsWorkspacePath(url) { + batch.gitStateMayHaveChanged = true + if !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) + || visibilityRules.isWatchedDespiteHiding(url, relativeTo: configuration.workspaceRoot) { + workspacePaths.insert(url.path) + } + } else if configuration.containsRepositoryPath(url) { + batch.gitStateMayHaveChanged = true + } + } + batch.workspacePaths = workspacePaths.sorted() + } + if !batch.isEmpty { onChange(batch) } + } + } + + deinit { stop() } +} + +/// The editor owns both local and remote observers through its existing port. +final class RemoteDocumentObservations: DocumentFileObservation, @unchecked Sendable { + private let local: any DocumentFileObservation + private let remote: [RemoteDirectoryObservation] + + init(local: any DocumentFileObservation, remote: [RemoteDirectoryObservation]) { + self.local = local + self.remote = remote + } + + func cancel() { + local.cancel() + remote.forEach { $0.stop() } + } + + deinit { cancel() } +} diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index 82364637b2..6fe5502083 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -53,7 +53,7 @@ final class RemoteWorkspaceRegistry: @unchecked Sendable { func resolve(_ url: URL) -> (binding: RemoteWorkspaceBinding, remotePath: String)? { let path = url.standardizedFileURL.path return lock.withLock { - for (root, binding) in bindings { + for (root, binding) in bindings.sorted(by: { $0.key.count > $1.key.count }) { if path == root { return (binding, binding.remoteRoot) } let prefix = root.hasSuffix("/") ? root : root + "/" guard path.hasPrefix(prefix) else { continue } @@ -101,6 +101,18 @@ struct ConnectionWatchingTransport: RemoteWorkspaceTransport { try watching(id) { try base.writeFile(data, to: path, expected: expected, session: id) } } + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + try watching(session) { try base.mutate(mutation, session: session) } + } + + func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + do { + try await base.watch(root: root, session: session, onEvent: onEvent) + } catch { + try watching(session) { throw error } + } + } + private func watching(_ id: String, _ work: () throws -> Value) throws -> Value { do { return try work() @@ -158,17 +170,14 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { } func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { - guard !registry.contains(rootURL) else { return } local.warmSearchIndex(at: rootURL, visibilityRules: visibilityRules) } func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) { - guard !registry.contains(rootURL) else { return } local.updateSearchIndex(at: rootURL, changedPaths: changedPaths, visibilityRules: visibilityRules) } func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { - guard !registry.contains(rootURL) else { return } local.invalidateSearchIndex(at: rootURL, visibilityRules: visibilityRules) } @@ -183,11 +192,17 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { let url = rootURL.appendingPathComponent(relativePath) - guard registry.contains(url) else { + guard let (binding, path) = registry.resolve(url) else { return local.writeFile(text, at: rootURL, relativePath: relativePath) } - // This legacy entry point has no version to check, and remote files - // cannot be created yet, so it never writes. + do { + let expected: RemoteFileVersion? + do { expected = try transport.readFile(path, session: binding.sessionID).version } + catch let failure as RemoteProbeFailure where failure.code == "workspace_not_found" { expected = nil } + if case .saved = try transport.writeFile(Data(text.utf8), to: path, expected: expected, session: binding.sessionID) { + return true + } + } catch { return false } return false } @@ -229,8 +244,8 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen private var versionOrder: [String] = [] private static let rememberedVersions = 512 /// Parent listings used to answer existence checks, so a burst of - /// `fileExists` calls costs one round trip; short-lived because remote - /// files are not watched. Writes drop them. + /// `fileExists` calls costs one round trip. Mutations and observed external + /// changes drop them; the short lifetime also covers inactive observers. private var listings: [String: (entries: [RemoteDirectoryEntry], at: Date)] = [:] private static let listingLifetime: TimeInterval = 3 @@ -273,11 +288,8 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen guard encoding == .utf8 || encoding == .utf8Bom else { throw CocoaError(.fileWriteInapplicableStringEncoding) } var data = Data(text.utf8) if encoding == .utf8Bom { data.insert(contentsOf: [0xEF, 0xBB, 0xBF], at: 0) } - // Without a version the host would create the file; remote files - // cannot be created yet. - guard let expectedIdentity else { throw CocoaError(.featureUnsupported) } - var expected = version(for: versionKey(binding, path, expectedIdentity)) - if expected == nil { + var expected = expectedIdentity.flatMap { version(for: versionKey(binding, path, $0)) } + if let expectedIdentity, expected == nil { // Opened before this session: compare the current bytes. let current = try transport.readFile(path, session: binding.sessionID) guard Self.identity(of: current.data) == expectedIdentity else { @@ -358,34 +370,50 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen } func createFile(at url: URL) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + guard url.standardizedFileURL != binding.root.standardizedFileURL else { throw CocoaError(.fileWriteFileExists) } + lock.withLock { listings.removeAll() } + guard case .saved = try transport.writeFile(Data(), to: path, expected: nil, session: binding.sessionID) else { + throw CocoaError(.fileWriteFileExists) + } + return + } try local.createFile(at: url) } func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + try mutate(.createDirectory(path: path, recursive: withIntermediateDirectories), binding: binding) + return + } try local.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories) } func copyItem(at sourceURL: URL, to destinationURL: URL) throws { - try remoteOnly(sourceURL) - try remoteOnly(destinationURL) + if try transfer(sourceURL, destinationURL, copying: true) { return } try local.copyItem(at: sourceURL, to: destinationURL) } func moveItem(at sourceURL: URL, to destinationURL: URL) throws { - try remoteOnly(sourceURL) - try remoteOnly(destinationURL) + if try transfer(sourceURL, destinationURL, copying: false) { return } try local.moveItem(at: sourceURL, to: destinationURL) } func removeItem(at url: URL) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + try requireNonRoot(url, binding: binding) + try mutate(.remove(path: path), binding: binding) + return + } try local.removeItem(at: url) } func trashItem(at url: URL) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + try requireNonRoot(url, binding: binding) + try mutate(.trash(path: path), binding: binding) + return + } try local.trashItem(at: url) } @@ -396,7 +424,12 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen func writeText(_ text: String, to url: URL) throws { guard registry.contains(url) else { return try local.writeText(text, to: url) } - _ = try remoteWrite(text, to: url, encoding: .utf8, expectedIdentity: nil) + let identity: String? + do { identity = try remoteRead(url)?.identity } + catch let error as CocoaError where error.code == .fileReadNoSuchFile { identity = nil } + if case .conflict = try remoteWrite(text, to: url, encoding: .utf8, expectedIdentity: identity) { + throw CocoaError(.fileWriteFileExists) + } } func readText(from url: URL) throws -> String { @@ -405,9 +438,47 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen } func observeDocuments(at urls: [URL], onChange: @escaping @Sendable ([URL]) -> Void) -> any DocumentFileObservation { - // Remote files are not watched yet; a changed file is detected as a - // conflict when it is saved. - local.observeDocuments(at: urls.filter { !registry.contains($0) }, onChange: onChange) + let localObservation = local.observeDocuments(at: urls.filter { !registry.contains($0) }, onChange: onChange) + let groups = Dictionary(grouping: urls.filter { registry.contains($0) }) { registry.resolve($0)!.binding.root } + let observations = groups.compactMap { root, documents -> RemoteDirectoryObservation? in + guard let binding = registry.binding(forRoot: root) else { return nil } + let observation = RemoteDirectoryObservation( + configuration: DirectoryWatchConfiguration(workspaceRoot: root, gitContext: nil), + visibilityRules: FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []), + binding: binding, registry: registry, transport: transport + ) { [weak self] batch in + self?.lock.withLock { self?.listings.removeAll() } + let affected = batch.requiresFullRescan ? documents : documents.filter { document in + batch.workspacePaths.contains { path in document.path == path || document.path.hasPrefix(path + "/") } + } + if !affected.isEmpty { onChange(affected) } + } + observation.start() + return observation + } + return RemoteDocumentObservations(local: localObservation, remote: observations) + } + + private func requireNonRoot(_ url: URL, binding: RemoteWorkspaceBinding) throws { + guard url.standardizedFileURL != binding.root.standardizedFileURL else { throw CocoaError(.fileWriteNoPermission) } + } + + private func mutate(_ mutation: RemoteFileMutation, binding: RemoteWorkspaceBinding) throws { + defer { lock.withLock { listings.removeAll() } } + try transport.mutate(mutation, session: binding.sessionID) + } + + private func transfer(_ source: URL, _ destination: URL, copying: Bool) throws -> Bool { + let from = registry.resolve(source) + let to = registry.resolve(destination) + guard from != nil || to != nil else { return false } + guard let from, let to, from.binding.sessionID == to.binding.sessionID else { + throw CocoaError(.featureUnsupported) + } + try requireNonRoot(source, binding: from.binding) + try requireNonRoot(destination, binding: to.binding) + try mutate(copying ? .copy(from: from.remotePath, to: to.remotePath) : .move(from: from.remotePath, to: to.remotePath), binding: from.binding) + return true } func readDocumentText(from url: URL) throws -> String? { diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index a189aa48d6..97122eceee 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -24,6 +24,7 @@ struct TerminalView: View { ) .litheWorkbenchSurface(LitheTheme.editor) .background(LitheToolWindowActivityTracker(isActive: $terminalToolActive)) + .task(id: model.workspaceURL) { feature.setShellWorkspace(model.workspaceURL) } .onAppear { terminalToolActive = (model.activeToolTerminalSession?.nativeView as? LitheTerminalView)?.hasFocus == true } @@ -240,8 +241,11 @@ struct TerminalView: View { } items += [ .separator, - .action("Detect Installed Shells") { feature.refreshAvailableShells() } + .action("Detect Installed Shells", isEnabled: !feature.isDiscoveringShells) { feature.refreshAvailableShells() } ] + if let error = feature.shellDiscoveryError { + items.append(.action(error, localizesTitle: false, isEnabled: false, action: {})) + } return items } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index 84f5384aa5..3541b61b0f 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -181,9 +181,12 @@ package struct BuildSystemPluginSource: Equatable, Sendable { package struct BuildSystemTarget: Equatable, Sendable { package let manifest: Data package let buildSystemID: String + /// Retains the owning host even for pure plans with only relative paths. + package let workspaceURL: URL? - package init(manifest: Data, buildSystemID: String) { + package init(manifest: Data, buildSystemID: String, workspaceURL: URL? = nil) { self.manifest = manifest; self.buildSystemID = buildSystemID + self.workspaceURL = workspaceURL } } @@ -519,10 +522,15 @@ package protocol BuildToolResolving: AnyObject { ) async throws -> ResolvedBuildTool /// Passive discovery for project import; must never probe or execute tools. func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? + /// Host discovery may require I/O; project import still never runs code. + func prepareLanguageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) async throws -> URL? } package extension BuildToolResolving { func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? { nil } + func prepareLanguageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) async throws -> URL? { + languageImportExecutable(invocation, workspaceURL: workspaceURL) + } } // MARK: Options @@ -1066,6 +1074,36 @@ package protocol BuildSystemStateLocating: Sendable { /// A fresh, not yet existing path for one `${outputFile}`. func allocateOutputFile(in workspaceStateDirectory: URL) throws -> URL func removeOutputFile(_ url: URL) + /// Resolves host-owned paths before Core expands command templates. The + /// returned cleanup retains that host even after a workspace reconnects. + func prepareRun(pluginID: String, buildSystemID: String, packageURL: URL?, + workspaceURL: URL, allocatesOutputFile: Bool) async throws -> BuildSystemRunResources +} + +package struct BuildSystemRunResources: Sendable { + package let pluginDirectory: URL? + package let workspaceStateDirectory: URL + package let outputFile: URL? + package let release: @Sendable () async -> Void + + package init(pluginDirectory: URL?, workspaceStateDirectory: URL, outputFile: URL?, + release: @escaping @Sendable () async -> Void) { + self.pluginDirectory = pluginDirectory + self.workspaceStateDirectory = workspaceStateDirectory + self.outputFile = outputFile + self.release = release + } +} + +extension BuildSystemStateLocating { + package func prepareRun(pluginID: String, buildSystemID: String, packageURL: URL?, + workspaceURL: URL, allocatesOutputFile: Bool) async throws -> BuildSystemRunResources { + let directory = try workspaceStateDirectory(pluginID: pluginID, buildSystemID: buildSystemID, workspaceURL: workspaceURL) + let output = try allocatesOutputFile ? allocateOutputFile(in: directory) : nil + return BuildSystemRunResources(pluginDirectory: packageURL, workspaceStateDirectory: directory, outputFile: output) { + if let output { removeOutputFile(output) } + } + } } // MARK: Processes diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 6b5633a847..cfb27725f5 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -358,6 +358,9 @@ package struct JavaLanguageImportConfiguration: Equatable, Sendable { } package protocol LanguageServerRuntimeCore: Sendable { + /// Network-backed startup must run off the main actor. This is resolved + /// from the workspace owner, before any process is created. + func requiresBackgroundLanguageServerStart(rootURL: URL) -> Bool func startLanguageServer( providerID: String, executableURL: URL, @@ -551,6 +554,8 @@ package protocol LanguageServerRuntimeCore: Sendable { } package extension LanguageServerRuntimeCore { + func requiresBackgroundLanguageServerStart(rootURL _: URL) -> Bool { false } + func startLanguageServer( providerID: String, executableURL: URL, arguments: [String], environment: [String: String], rootURL: URL, workingDirectoryURL: URL, initializationOptions: ToolingJSONValue?, diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 572a691ce0..564ff79520 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -1142,6 +1142,7 @@ package protocol LanguageProviderRuntime: AnyObject { var supportsLanguageServerSession: Bool { get } var unavailableToolingMessage: String? { get } func makeLanguageServerSession() -> (any LanguageServerSession)? + func makeLanguageServerSession(rootURL: URL) -> (any LanguageServerSession)? } @MainActor @@ -1168,4 +1169,5 @@ package extension LanguageProviderRuntime { var supportsLanguageServerSession: Bool { false } var unavailableToolingMessage: String? { nil } func makeLanguageServerSession() -> (any LanguageServerSession)? { nil } + func makeLanguageServerSession(rootURL: URL) -> (any LanguageServerSession)? { makeLanguageServerSession() } } diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index f317401eef..b2ac41f1b0 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -43,6 +43,7 @@ package struct BuildSystemEntry: Equatable, Identifiable, Sendable { package let instance: BuildSystemInstance package let declaration: DeclarativeBuildSystem package let source: BuildSystemPluginSource + package let workspaceURL: URL? package var model: BuildModel? package var modelState: BuildModelState package var options: [BuildResolvedOption] @@ -55,13 +56,14 @@ package struct BuildSystemEntry: Equatable, Identifiable, Sendable { package var id: String { instance.id } package var target: BuildSystemTarget { - BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID) + BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID, workspaceURL: workspaceURL) } package init( instance: BuildSystemInstance, declaration: DeclarativeBuildSystem, source: BuildSystemPluginSource, + workspaceURL: URL? = nil, model: BuildModel? = nil, modelState: BuildModelState = .notLoaded, options: [BuildResolvedOption] = [], @@ -70,6 +72,7 @@ package struct BuildSystemEntry: Equatable, Identifiable, Sendable { dependencies: [String: BuildDependencyState] = [:] ) { self.instance = instance; self.declaration = declaration; self.source = source + self.workspaceURL = workspaceURL self.model = model; self.modelState = modelState; self.options = options; self.sync = sync self.isReloadRequired = isReloadRequired; self.dependencies = dependencies } @@ -340,7 +343,16 @@ package final class BuildSystemService: ObservableObject { settings: self.settings(input.entry.source.pluginID) ) else { continue } - executables[input.entry.id] = self.tools.languageImportExecutable(invocation, workspaceURL: workspaceURL) + do { + executables[input.entry.id] = try await self.tools.prepareLanguageImportExecutable(invocation, workspaceURL: workspaceURL) + } catch { + guard self.generation == generation, self.detectionID == detectionID, + !Task.isCancelled else { return } + self.failLanguageImport(error) + return + } + guard self.generation == generation, self.detectionID == detectionID, + !Task.isCancelled else { return } } let withExecutables = executables let configured = await Task.detached(priority: .userInitiated) { @@ -602,9 +614,7 @@ package final class BuildSystemService: ObservableObject { owner: CommandOwner ) async -> Result { do { - let context = try planContext(entry, workspaceURL: workspaceURL) - let outputFile = try context.workspaceStateDirectory.map { try stateLocator.allocateOutputFile(in: $0) } - defer { if let outputFile { stateLocator.removeOutputFile(outputFile) } } + return .success(try await withPlanContext(entry, workspaceURL: workspaceURL, allocatesOutputFile: true) { context, outputFile in let planner = planner let plan = try await outputPlan(entry, workspaceURL: workspaceURL, reason: reason, owner: owner) { hints in try planner.modelPlan(entry.target, context: context, outputFile: outputFile, providerHints: hints) @@ -621,7 +631,7 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + return try await Task.detached(priority: .userInitiated) { let model = try planner.decodeModel( target, workspaceURL: workspaceURL, root: root, output: output, options: context.options ) @@ -635,7 +645,8 @@ package final class BuildSystemService: ObservableObject { projects: model.projects.map(\.path), options: context.options ) ) - }.value) + }.value + }) } catch { return .failure(error) } @@ -648,7 +659,7 @@ package final class BuildSystemService: ObservableObject { package func runTask(_ entryID: String, request: BuildTaskRequest, title: String) -> UUID? { guard let workspaceURL, let entry = entry(entryID) else { return nil } return startRun(entryID, title: title) { service, run in - let context = try service.planContext(entry, workspaceURL: workspaceURL) + try await service.withPlanContext(entry, workspaceURL: workspaceURL) { context, _ in let plan = try service.planner.taskPlan(entry.target, context: context, request: request) let collected = try await service.runStreamedCommand( plan, entry: entry, workspaceURL: workspaceURL, owner: .run(run) @@ -659,6 +670,7 @@ package final class BuildSystemService: ObservableObject { guard service.isCurrent(run) else { throw CancellationError() } service.diagnostics = diagnostics return collected.exitCode + } } } @@ -666,7 +678,7 @@ package final class BuildSystemService: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { guard let workspaceURL, let entry = entry(entryID) else { return nil } return startRun(entryID, title: title) { service, run in - let context = try service.planContext(entry, workspaceURL: workspaceURL) + try await service.withPlanContext(entry, workspaceURL: workspaceURL) { context, _ in let plan = try service.planner.testPlan(entry.target, context: context, request: request) if plan.results != nil, request.project == nil, entry.instance.modelSource != nil, @@ -714,6 +726,7 @@ package final class BuildSystemService: ObservableObject { } } return collected.exitCode + } } } @@ -905,9 +918,7 @@ package final class BuildSystemService: ObservableObject { owner: CommandOwner ) async -> Result { do { - let context = try planContext(entry, workspaceURL: workspaceURL) - let outputFile = try context.workspaceStateDirectory.map { try stateLocator.allocateOutputFile(in: $0) } - defer { if let outputFile { stateLocator.removeOutputFile(outputFile) } } + return .success(try await withPlanContext(entry, workspaceURL: workspaceURL, allocatesOutputFile: true) { context, outputFile in let planner = planner let selection = project.map(BuildSelection.init(project:)) let plan = try await outputPlan(entry, workspaceURL: workspaceURL, reason: reason, owner: owner) { hints in @@ -927,11 +938,12 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + return try await Task.detached(priority: .userInitiated) { try planner.decodeDependencies( target, workspaceURL: workspaceURL, root: root, project: project?.path, output: output ) - }.value) + }.value + }) } catch { return .failure(error) } @@ -1156,23 +1168,35 @@ package final class BuildSystemService: ObservableObject { ))?.fingerprint } - private func planContext(_ entry: BuildSystemEntry, workspaceURL: URL) throws -> BuildPlanContext { + private func withPlanContext( + _ entry: BuildSystemEntry, workspaceURL: URL, allocatesOutputFile: Bool = false, + perform: (BuildPlanContext, URL?) async throws -> Value + ) async throws -> Value { let stored = storedOptions(for: entry, workspaceURL: workspaceURL) let resolution = try? planner.resolveOptions( entry.target, options: stored, modelValues: entry.model?.optionValues ) - return BuildPlanContext( + let resources = try await stateLocator.prepareRun( + pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, + packageURL: entry.source.packageURL, workspaceURL: workspaceURL, allocatesOutputFile: allocatesOutputFile + ) + let context = BuildPlanContext( root: entry.instance.root, // A plan gets only values Core still accepts for the current // model; otherwise a removed Cargo feature would reach the argv. options: Self.effectiveOptionValues(resolution, stored: stored), - pluginDirectory: entry.source.packageURL, - workspaceStateDirectory: try stateLocator.workspaceStateDirectory( - pluginID: entry.instance.pluginID, - buildSystemID: entry.instance.buildSystemID, - workspaceURL: workspaceURL - ) + pluginDirectory: resources.pluginDirectory, + workspaceStateDirectory: resources.workspaceStateDirectory ) + do { + try Task.checkCancellation() + let value = try await perform(context, resources.outputFile) + await resources.release() + return value + } catch { + await resources.release() + throw error + } } /// Stored values reduced to the ones Core accepted in `resolution`. Core @@ -1490,7 +1514,7 @@ package final class BuildSystemService: ObservableObject { let declaration = source.buildSystems.first(where: { $0.id == instance.buildSystemID }) else { return nil } let stored = storedOptions[instance.buildSystemID] ?? [:] - let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID) + let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID, workspaceURL: workspaceURL) let earlier = previous[instance.id] // The instance ID is the build-system ID plus its root, so another // plugin can take the same ID over. Its model and dependencies are @@ -1529,6 +1553,7 @@ package final class BuildSystemService: ObservableObject { instance: instance, declaration: declaration, source: source, + workspaceURL: workspaceURL, model: model, modelState: modelState, options: resolution?.options ?? [], diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift index 717b65ad21..464bc1a8c5 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift @@ -22,6 +22,29 @@ package struct ResolvedLanguageServerLaunch: Equatable, Sendable { } } +/// An asynchronous host adapter supplies a complete process and its lifetime +/// before startup. Network work must finish before Core starts the process. +package struct PreparedLanguageServerProcess: Sendable { + package let executableURL: URL + package let environment: [String: String] + package let launch: ResolvedLanguageServerLaunch + package let core: any LanguageServerRuntimeCore + package let release: @Sendable () async -> Void + + package init(executableURL: URL, environment: [String: String], launch: ResolvedLanguageServerLaunch, + core: any LanguageServerRuntimeCore, release: @escaping @Sendable () async -> Void) { + self.executableURL = executableURL + self.environment = environment + self.launch = launch + self.core = core + self.release = release + } +} + +package typealias WorkspaceLanguageServerSessionFactory = @MainActor ( + LanguageServerLaunchDescriptor, URL +) -> (any LanguageServerSession)? + /// Resolves a declarative launch for one workspace root. It runs when the /// session starts, because the workspace state directory depends on the root. package typealias LanguageServerLaunchResolver = @MainActor ( @@ -39,6 +62,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { private let hostIntegrationResolver: LanguageServerHostIntegrationResolver? private let languageServerLaunchResolver: LanguageServerLaunchResolver? private let languageServerCacheDirectory: URL? + private let workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID private var runtimeUnavailableMessage: String? @@ -62,6 +86,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { hostIntegrationResolver: LanguageServerHostIntegrationResolver? = nil, languageServerLaunchResolver: LanguageServerLaunchResolver? = nil, languageServerCacheDirectory: URL? = nil, + workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence ) { @@ -73,6 +98,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { self.hostIntegrationResolver = hostIntegrationResolver self.languageServerLaunchResolver = languageServerLaunchResolver self.languageServerCacheDirectory = languageServerCacheDirectory + self.workspaceSessionFactory = workspaceSessionFactory self.processRegistry = processRegistry self.moduleID = moduleID } @@ -142,6 +168,14 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { ) } + package func makeLanguageServerSession(rootURL: URL) -> (any LanguageServerSession)? { + if languageServerCore.requiresBackgroundLanguageServerStart(rootURL: rootURL), + let workspaceSessionFactory, let languageServerLaunch { + return workspaceSessionFactory(languageServerLaunch, rootURL) + } + return makeLanguageServerSession() + } + } @MainActor @@ -152,6 +186,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime private let hostIntegrationResolver: LanguageServerHostIntegrationResolver? private let languageServerLaunchResolver: LanguageServerLaunchResolver? private let languageServerCacheDirectory: URL? + private let workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID @@ -162,6 +197,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime hostIntegrationResolver: LanguageServerHostIntegrationResolver? = nil, languageServerLaunchResolver: LanguageServerLaunchResolver? = nil, languageServerCacheDirectory: URL? = nil, + workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence ) { @@ -171,6 +207,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime self.hostIntegrationResolver = hostIntegrationResolver self.languageServerLaunchResolver = languageServerLaunchResolver self.languageServerCacheDirectory = languageServerCacheDirectory + self.workspaceSessionFactory = workspaceSessionFactory self.processRegistry = processRegistry self.moduleID = moduleID } @@ -191,6 +228,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime hostIntegrationResolver: hostIntegrationResolver, languageServerLaunchResolver: languageServerLaunchResolver, languageServerCacheDirectory: languageServerCacheDirectory, + workspaceSessionFactory: workspaceSessionFactory, processRegistry: processRegistry, moduleID: moduleID ) @@ -210,6 +248,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime hostIntegrationResolver: hostIntegrationResolver, languageServerLaunchResolver: languageServerLaunchResolver, languageServerCacheDirectory: languageServerCacheDirectory, + workspaceSessionFactory: workspaceSessionFactory, processRegistry: processRegistry, moduleID: ownerModuleID ) diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 3f65d39748..7747dfb716 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -29,17 +29,21 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private static let idlePollNanoseconds: UInt64 = 50_000_000 private let providerID: String - private let executableURL: URL - private let arguments: [String] - private let environment: [String: String] - private let initializationOptions: ToolingJSONValue? + private var executableURL: URL + private var arguments: [String] + private var environment: [String: String] + private var initializationOptions: ToolingJSONValue? + private var preparedWorkspaceConfiguration: ToolingJSONValue? + private let prepareProcess: (@MainActor (URL) async throws -> PreparedLanguageServerProcess)? + private var preparationTask: Task? + private var startingPreparedProcess = false private let launchResolver: (@MainActor (URL) throws -> ResolvedLanguageServerLaunch)? private let hostIntegrationLaunch: LanguageServerHostIntegrationLaunch? private let cacheDirectoryURL: URL? private let initializeTimeout: TimeInterval private let requestTimeout: TimeInterval private let shutdownTimeout: TimeInterval - private let core: any LanguageServerRuntimeCore + private var core: any LanguageServerRuntimeCore private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID /// The plugin that owns the process; nil for host-owned servers, which @@ -49,6 +53,18 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private let hostServices: [String] private var sessionID: String? + private var startupGeneration: UUID? + private var usesBackgroundStart = false + private struct StartingDocument { + let text: String + let languageID: String + } + /// Latest full snapshots replace incremental edits while the remote + /// process is starting. They are flushed once, before polling its events. + private var startingDocuments: [URL: StartingDocument] = [:] + private var startingDocumentBytes = 0 + private var startingFileChanges: [LanguageServerWorkspaceFileChange] = [] + private static let startingDocumentByteLimit = 8 * 1024 * 1024 private var pendingOperations: [String: PendingOperation] = [:] /// Progress listeners of running plugin functions, by operation ID. private var functionProgress: [String: @MainActor @Sendable (PluginFunctionProgress) -> Void] = [:] @@ -78,6 +94,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Called once the session has ended and handed its process back to /// Core, so the owner can end what the process left running. package var onReleased: (@MainActor () -> Void)? + /// Package snapshots may be removed only after Core has ended the process. + package var releaseResources: (@Sendable () async -> Void)? package var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? package var onLog: ((LanguageServerLogLevel, String, String?, String?) -> Void)? @@ -111,6 +129,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { arguments: [String], environment: [String: String], initializationOptions: ToolingJSONValue? = nil, + prepareProcess: (@MainActor (URL) async throws -> PreparedLanguageServerProcess)? = nil, launchResolver: (@MainActor (URL) throws -> ResolvedLanguageServerLaunch)? = nil, hostIntegrationLaunch: LanguageServerHostIntegrationLaunch? = nil, cacheDirectoryURL: URL? = nil, @@ -128,6 +147,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.arguments = arguments self.environment = environment self.initializationOptions = initializationOptions + self.prepareProcess = prepareProcess self.launchResolver = launchResolver self.hostIntegrationLaunch = hostIntegrationLaunch self.cacheDirectoryURL = cacheDirectoryURL @@ -144,6 +164,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Derived from the last lifecycle state Rust published: there is no local /// process handle to ask. package var isRunning: Bool { + if startupGeneration != nil { return true } guard sessionID != nil else { return false } switch state { case .stopped, .failed: @@ -171,12 +192,54 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { mavenContext: MavenLaunchContext?, javaGradle: JavaGradleImportConfiguration? ) throws { - guard sessionID == nil else { return } + guard sessionID == nil, startupGeneration == nil else { return } let normalizedRoot = rootURL.standardizedFileURL transition(to: .startingProcess) + if let prepareProcess, !startingPreparedProcess { + let generation = UUID() + startupGeneration = generation + preparationTask = Task { @MainActor [weak self] in + do { + let prepared = try await prepareProcess(normalizedRoot) + guard let self, self.startupGeneration == generation, !Task.isCancelled else { + await prepared.release() + return + } + self.preparationTask = nil + self.startupGeneration = nil + self.executableURL = prepared.executableURL + self.arguments = prepared.launch.arguments + self.environment = prepared.environment + self.initializationOptions = prepared.launch.initializationOptions + self.preparedWorkspaceConfiguration = prepared.launch.workspaceConfiguration + self.core = prepared.core + self.releaseResources = prepared.release + self.startingPreparedProcess = true + defer { self.startingPreparedProcess = false } + do { + try self.start(rootURL: normalizedRoot, workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, javaGradle: javaGradle) + } catch { + self.releaseSession() + } + } catch { + guard let self, self.startupGeneration == generation else { return } + self.preparationTask = nil + self.startupGeneration = nil + self.startingDocuments = [:] + self.startingDocumentBytes = 0 + self.startingFileChanges = [] + let failure = LanguageServerSessionFailure(code: (error as? DeclarativePluginRejection)?.code ?? "plugin.launch.failed", + stage: "processStart", message: error.localizedDescription) + self.transition(to: .failed(failure)) + self.releaseSession() + } + } + return + } var arguments = arguments var initializationOptions = initializationOptions - var workspaceConfiguration: ToolingJSONValue? + var workspaceConfiguration = preparedWorkspaceConfiguration if let launchResolver { do { let resolved = try launchResolver(normalizedRoot) @@ -201,54 +264,117 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { ([executableURL.path] + arguments).joined(separator: " "), nil ) - let started = if let pluginID, !hostServices.isEmpty { - core.startPluginLanguageServer( - pluginID: pluginID, - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - initializationOptions: initializationOptions, - hostServices: hostServices, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) - } else if let pluginID { - core.startPluginLanguageServer( - pluginID: pluginID, - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - initializationOptions: initializationOptions, - workspaceConfiguration: workspaceConfiguration, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) - } else { - core.startLanguageServer( - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - workingDirectoryURL: normalizedRoot, - initializationOptions: initializationOptions, - runtimeExecutableURL: hostIntegrationLaunch?.runtimeExecutableURL, - jdtlsLaunchResources: hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources, - cacheDirectoryURL: cacheDirectoryURL, - workspaceFingerprint: workspaceFingerprint, - mavenContext: mavenContext, - javaGradle: javaGradle, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) + let core = core + let pluginID = pluginID + let hostServices = hostServices + let providerID = providerID + let executableURL = executableURL + let environment = environment + let initializeTimeout = initializeTimeout + let requestTimeout = requestTimeout + let shutdownTimeout = shutdownTimeout + let hostIntegrationLaunch = hostIntegrationLaunch + let cacheDirectoryURL = cacheDirectoryURL + let resolvedArguments = arguments + let resolvedOptions = initializationOptions + let resolvedConfiguration = workspaceConfiguration + let launch: @Sendable () -> Result = { + if let pluginID, !hostServices.isEmpty { + core.startPluginLanguageServer( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: resolvedOptions, + hostServices: hostServices, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else if let pluginID { + core.startPluginLanguageServer( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: resolvedOptions, + workspaceConfiguration: resolvedConfiguration, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else { + core.startLanguageServer( + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + workingDirectoryURL: normalizedRoot, + initializationOptions: resolvedOptions, + runtimeExecutableURL: hostIntegrationLaunch?.runtimeExecutableURL, + jdtlsLaunchResources: hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, + javaGradle: javaGradle, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } + } + usesBackgroundStart = core.requiresBackgroundLanguageServerStart(rootURL: normalizedRoot) + if usesBackgroundStart { + let generation = UUID() + startupGeneration = generation + let resources = releaseResources + releaseResources = nil + Task.detached(priority: .userInitiated) { [weak self] in + let started = launch() + // The completion claims the result only while its generation + // is active; a closed/replaced owner destroys the late session. + if await self?.completeBackgroundStart(started, generation: generation, resources: resources) != true { + if case .success(let payload) = started { core.destroyLanguageServer(sessionID: payload.sessionID) } + await resources?() + } + } + return + } + do { try acceptStart(launch()) } + catch { + if sessionID != nil { stop() } + else { releaseSession() } + throw error } + } + + private func completeBackgroundStart( + _ result: Result, + generation: UUID, + resources: (@Sendable () async -> Void)? + ) -> Bool { + guard startupGeneration == generation else { return false } + startupGeneration = nil + releaseResources = resources + do { + try acceptStart(result) + } catch { + startingDocuments = [:] + startingDocumentBytes = 0 + if sessionID != nil { + onLog?(.error, "Language server document sync failed", error.localizedDescription, nil) + stop() + } else { releaseSession() } + } + return true + } + + private func acceptStart(_ started: Result) throws { switch started { case .success(let payload): sessionID = payload.sessionID @@ -258,6 +384,16 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } transition(to: Self.sessionState(payload.state) ?? .initializing) startPolling() + let changes = startingFileChanges + startingFileChanges = [] + if !changes.isEmpty { try notifyWorkspaceFilesChanged(changes) } + let documents = startingDocuments + startingDocuments = [:] + startingDocumentBytes = 0 + for url in documents.keys.sorted(by: { $0.path < $1.path }) { + guard let document = documents[url] else { continue } + try synchronize(fileURL: url, text: document.text, languageID: document.languageID) + } case .failure(let error): let message = "Language server failed to start: \(error.userMessage)" let failure = LanguageServerSessionFailure( @@ -272,6 +408,16 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func synchronize(fileURL: URL, text: String, languageID: String) throws { + if startupGeneration != nil { + let url = fileURL.standardizedFileURL + let bytes = startingDocumentBytes - (startingDocuments[url]?.text.utf8.count ?? 0) + text.utf8.count + guard bytes <= Self.startingDocumentByteLimit, startingDocuments[url] != nil || startingDocuments.count < 512 else { + throw LanguageServerRuntimeSessionError.documentSyncFailed(L10n.string("Remote pending documents exceeded the startup limit. Wait for the language server to start.")) + } + startingDocuments[url] = StartingDocument(text: text, languageID: languageID) + startingDocumentBytes = bytes + return + } guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } // Documents synced before initialize completes are held by the runtime and // opened once the server is ready, so there is nothing to queue here. @@ -294,6 +440,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { languageID: String, changes: [LanguageServerDocumentChange] ) throws { + if startupGeneration != nil { + return try synchronize(fileURL: fileURL, text: text, languageID: languageID) + } guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } guard let incrementalCore = core as? any IncrementalLanguageServerRuntimeCore else { return try synchronize(fileURL: fileURL, text: text, languageID: languageID) @@ -314,8 +463,15 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package func notifyWorkspaceFilesChanged( _ changes: [LanguageServerWorkspaceFileChange] ) throws { - guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } guard !changes.isEmpty else { return } + if startupGeneration != nil { + guard changes.count <= 4096 - startingFileChanges.count else { + throw LanguageServerRuntimeSessionError.notReady + } + startingFileChanges.append(contentsOf: changes) + return + } + guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } switch core.notifyLanguageServerWorkspaceFilesChanged( sessionID: sessionID, changes: changes @@ -339,6 +495,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func closeDocument(_ fileURL: URL) { + if let removed = startingDocuments.removeValue(forKey: fileURL.standardizedFileURL) { + startingDocumentBytes -= removed.text.utf8.count + } guard let sessionID else { return } documentVersions[fileURL.standardizedFileURL] = nil // The runtime owns which documents are open, so closing one it does not @@ -874,11 +1033,19 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func stop() { + let wasStarting = startupGeneration != nil + preparationTask?.cancel() + preparationTask = nil + startupGeneration = nil + startingDocuments = [:] + startingDocumentBytes = 0 + startingFileChanges = [] configurationUpdateGeneration &+= 1 configurationUpdateTask = nil guard let sessionID else { failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) transition(to: .stopped) + if wasStarting || releaseResources != nil { releaseSession() } return } // The runtime sends the shutdown, force-terminates on its own deadline, @@ -895,6 +1062,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { watchdog.arm(after: stuckSendGrace) { [weak self] in self?.endStuckSend() } stopWatchdog = watchdog } + } else if usesBackgroundStart { + let core = core + Task.detached(priority: .userInitiated) { core.stopLanguageServer(sessionID: sessionID) } } else { core.stopLanguageServer(sessionID: sessionID) } @@ -1144,13 +1314,28 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Hands the session back to the runtime once it has reached a terminal state. private func releaseSession() { + startingDocuments = [:] + startingDocumentBytes = 0 + startingFileChanges = [] pollTask = nil stopWatchdog?.disarm() stopWatchdog = nil functionProgress = [:] failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) + let releaseResources = releaseResources + self.releaseResources = nil if let sessionID { - core.destroyLanguageServer(sessionID: sessionID) + if usesBackgroundStart || releaseResources != nil { + let core = core + Task.detached(priority: .userInitiated) { + core.destroyLanguageServer(sessionID: sessionID) + await releaseResources?() + } + } else { + core.destroyLanguageServer(sessionID: sessionID) + } + } else if let releaseResources { + Task.detached { await releaseResources() } } sessionID = nil documentVersions = [:] diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 3e9236cfc6..6636a05135 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -1632,7 +1632,7 @@ package final class LanguageToolingSessionManager: ObservableObject, detail: descriptor.languageServerLaunch?.executableNames.joined(separator: ", ") ) - guard let created = runtime.makeLanguageServerSession() else { + guard let created = runtime.makeLanguageServerSession(rootURL: rootURL) else { let message = runtime.unavailableToolingMessage ?? descriptor.displayName let state = LanguageServerSessionState.failed(LanguageServerSessionFailure( code: "tool_not_found", diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index 41267cb7f3..1b2b77316c 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -8,21 +8,68 @@ public final class TerminalFeatureModel: ObservableObject { @Published public private(set) var activeTerminalSessionID: UUID? @Published public private(set) var availableShells: [String] = [] + @Published public private(set) var isDiscoveringShells = false + @Published public private(set) var shellDiscoveryError: String? private let terminalFactory: () -> any TerminalTransport + private let workspaceTerminalFactory: ((URL) -> any TerminalTransport)? private let shellDiscovery: () -> [String] + private let workspaceShellDiscovery: ((URL) async throws -> [String]?)? + private var shellWorkspace: URL? + private var shellDiscoveryTask: Task? + private var shellDiscoveryGeneration = UUID() + private var pendingProcessSessions: [UUID: TerminalSession] = [:] public init( terminalFactory: @escaping () -> any TerminalTransport, - shellDiscovery: @escaping () -> [String] = { [] } + workspaceTerminalFactory: ((URL) -> any TerminalTransport)? = nil, + shellDiscovery: @escaping () -> [String] = { [] }, + workspaceShellDiscovery: ((URL) async throws -> [String]?)? = nil ) { self.terminalFactory = terminalFactory + self.workspaceTerminalFactory = workspaceTerminalFactory self.shellDiscovery = shellDiscovery + self.workspaceShellDiscovery = workspaceShellDiscovery refreshAvailableShells() } public func refreshAvailableShells() { - availableShells = shellDiscovery() + shellDiscoveryTask?.cancel() + let generation = UUID() + shellDiscoveryGeneration = generation + shellDiscoveryError = nil + guard let root = shellWorkspace, let discover = workspaceShellDiscovery else { + isDiscoveringShells = false + availableShells = shellDiscovery() + return + } + // Clear the previous host before starting I/O; stale results must never + // offer a local executable in a remote workspace's shell menu. + availableShells = [] + isDiscoveringShells = true + shellDiscoveryTask = Task { [weak self] in + do { + let shells = try await discover(root) + guard let self, !Task.isCancelled, shellDiscoveryGeneration == generation else { return } + availableShells = shells ?? shellDiscovery() + isDiscoveringShells = false + shellDiscoveryTask = nil + } catch { + guard let self, !Task.isCancelled, shellDiscoveryGeneration == generation else { return } + shellDiscoveryError = error.localizedDescription + isDiscoveringShells = false + shellDiscoveryTask = nil + } + } + } + + /// Binds discovery to the window's workspace, independently of which + /// terminal tab is selected or whether a terminal has been created yet. + public func setShellWorkspace(_ root: URL?) { + let root = root?.standardizedFileURL + guard shellWorkspace != root else { return } + shellWorkspace = root + if workspaceShellDiscovery != nil { refreshAvailableShells() } } public var activeTerminalSession: TerminalSession? { @@ -30,6 +77,11 @@ public final class TerminalFeatureModel: ObservableObject { return terminalSessions.first { $0.id == activeTerminalSessionID } } + /// A launch awaiting host acknowledgement still owns a process resource. + public var hasActiveSessions: Bool { + !pendingProcessSessions.isEmpty || terminalSessions.contains { $0.isRunning || $0.startedAt != nil && $0.endedAt == nil } + } + public func terminalTitle(for session: TerminalSession) -> String { if let processTitle = session.processTitle, !processTitle.isEmpty { return processTitle } guard let index = terminalSessions.firstIndex(where: { $0.id == session.id }) else { return "Local" } @@ -51,7 +103,8 @@ public final class TerminalFeatureModel: ObservableObject { preferredToolTabTitle: String? = nil, showsWorkbenchBackground: Bool = false ) -> TerminalSession { - let transport = terminalFactory() + setShellWorkspace(workspaceURL) + let transport = workspaceTerminalFactory?(workspaceURL) ?? terminalFactory() transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) let session = TerminalSession(transport: transport, preferredToolTabTitle: preferredToolTabTitle) session.start(in: workspaceURL, shellPath: shellPath) @@ -66,7 +119,7 @@ public final class TerminalFeatureModel: ObservableObject { showsWorkbenchBackground: Bool = false, onOutput: ((String) -> Void)? = nil ) throws -> (session: TerminalSession, processID: Int32) { - let transport = terminalFactory() + let transport = workspaceTerminalFactory?(URL(fileURLWithPath: launch.workingDirectory)) ?? terminalFactory() transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) let session = TerminalSession(transport: transport) session.onOutput = onOutput @@ -76,6 +129,26 @@ public final class TerminalFeatureModel: ObservableObject { return (session, processID) } + /// Uses workspace identity for host selection even when a debugger supplies + /// an absolute working directory on that host. + public func createProcessSessionAsync( + _ launch: TerminalProcessLaunch, + workspaceURL: URL, + showsWorkbenchBackground: Bool = false, + onOutput: ((String) -> Void)? = nil + ) async throws -> (session: TerminalSession, processID: Int32) { + let transport = workspaceTerminalFactory?(workspaceURL) ?? terminalFactory() + transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + let session = TerminalSession(transport: transport) + session.onOutput = onOutput + pendingProcessSessions[session.id] = session + defer { pendingProcessSessions[session.id] = nil } + let processID = try await session.startProcessAsync(launch) + terminalSessions.append(session) + activeTerminalSessionID = session.id + return (session, processID) + } + @discardableResult public func selectSession(_ session: TerminalSession) -> Bool { guard terminalSessions.contains(where: { $0.id == session.id }) else { return false } @@ -110,6 +183,9 @@ public final class TerminalFeatureModel: ObservableObject { } public func stopAllSessions() { + cancelShellDiscovery() + pendingProcessSessions.values.forEach { $0.stop() } + pendingProcessSessions.removeAll() terminalSessions.forEach { $0.stop() } terminalSessions.removeAll() activeTerminalSessionID = nil @@ -117,7 +193,10 @@ public final class TerminalFeatureModel: ObservableObject { /// Stops every session and waits until their process sessions are gone. public func stopAllSessionsAndWait() async { - let sessions = terminalSessions + let discovery = shellDiscoveryTask + cancelShellDiscovery() + let sessions = terminalSessions + pendingProcessSessions.values + pendingProcessSessions.removeAll() terminalSessions.removeAll() activeTerminalSessionID = nil // Started together so the bounded grace periods overlap. @@ -125,5 +204,15 @@ public final class TerminalFeatureModel: ObservableObject { Task { @MainActor in await session.stopAndWait() } } for stop in stops { await stop.value } + await discovery?.value } + + private func cancelShellDiscovery() { + shellDiscoveryGeneration = UUID() + shellDiscoveryTask?.cancel() + shellDiscoveryTask = nil + isDiscoveringShells = false + } + + deinit { shellDiscoveryTask?.cancel() } } diff --git a/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift b/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift index a86ec855d8..a0bf72d47b 100644 --- a/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift +++ b/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift @@ -14,22 +14,30 @@ public final class TerminalModule: LitheModule { public let manifest = moduleManifest private let terminalFactory: @MainActor () -> any TerminalTransport + private let workspaceTerminalFactory: (@MainActor (URL) -> any TerminalTransport)? private let shellDiscovery: @MainActor () -> [String] + private let workspaceShellDiscovery: (@MainActor (URL) async throws -> [String]?)? private var capability: TerminalModuleCapability? public init( terminalFactory: @escaping @MainActor () -> any TerminalTransport, - shellDiscovery: @escaping @MainActor () -> [String] = { [] } + workspaceTerminalFactory: (@MainActor (URL) -> any TerminalTransport)? = nil, + shellDiscovery: @escaping @MainActor () -> [String] = { [] }, + workspaceShellDiscovery: (@MainActor (URL) async throws -> [String]?)? = nil ) { self.terminalFactory = terminalFactory + self.workspaceTerminalFactory = workspaceTerminalFactory self.shellDiscovery = shellDiscovery + self.workspaceShellDiscovery = workspaceShellDiscovery } public func activate(context: ModuleContext) async throws { guard capability == nil else { return } let feature = TerminalFeatureModel( terminalFactory: terminalFactory, - shellDiscovery: shellDiscovery + workspaceTerminalFactory: workspaceTerminalFactory, + shellDiscovery: shellDiscovery, + workspaceShellDiscovery: workspaceShellDiscovery ) let resource = TerminalSessionResource(feature: feature) context.resources.register(resource) @@ -37,7 +45,7 @@ public final class TerminalModule: LitheModule { } public func prepareForSleep() async throws { - guard capability?.feature.terminalSessions.allSatisfy({ !$0.isRunning }) != false else { + guard capability?.feature.hasActiveSessions != true else { throw TerminalModuleSleepError.runningSession } } @@ -73,6 +81,6 @@ private final class TerminalSessionResource: ModuleResource { let feature: TerminalFeatureModel init(feature: TerminalFeatureModel) { self.feature = feature } var moduleResourceKind: String { "terminal-sessions" } - var isModuleResourceActive: Bool { feature.terminalSessions.contains(where: \.isRunning) } + var isModuleResourceActive: Bool { feature.hasActiveSessions } func stopModuleResource() async { await feature.stopAllSessionsAndWait() } } diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index 3e4c7eb30b..1bcea35a26 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -73,6 +73,9 @@ public protocol TerminalTransport: AnyObject { /// Whether the host terminal renders a light background, advertised through `COLORFGBG` /// so TUIs pick a palette without querying; hosts that cannot tell keep it unset. var reportsLightBackground: Bool? { get } + /// Network PTYs become ready after a remote acknowledgement. Local PTYs + /// keep their existing synchronous launch behavior. + var requiresAsyncStart: Bool { get } /// Applies the host surface state to the native view before the shell /// starts, so the exported `COLORFGBG` matches the OSC 11 answer the @@ -82,7 +85,10 @@ public protocol TerminalTransport: AnyObject { func defaultShellPath() -> String func defaultEnvironment() -> [String: String] func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws + func startShell(workingDirectory: String, shellPath: String, environment: [String: String]) async throws func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 + /// Waits for the host to acknowledge a managed process without blocking UI work. + func startProcessAsync(_ launch: TerminalProcessLaunch, environment: [String: String]) async throws -> Int32 func send(_ input: Data) throws func interrupt() throws func focus() @@ -99,6 +105,13 @@ public protocol TerminalTransport: AnyObject { } public extension TerminalTransport { + var requiresAsyncStart: Bool { false } + func startShell(workingDirectory: String, shellPath: String, environment: [String: String]) async throws { + try start(workingDirectory: workingDirectory, shellPath: shellPath, environment: environment) + } + func startProcessAsync(_ launch: TerminalProcessLaunch, environment: [String: String]) async throws -> Int32 { + try startProcess(launch, environment: environment) + } var termProgramVersion: String? { nil } func stopAndWait() async { stop() } func runningPrograms() -> [TerminalRunningProgram] { [] } diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index ec43d02437..c0383824e1 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -27,6 +27,8 @@ public final class TerminalSession: ObservableObject, Identifiable { private var workspaceURL: URL? private var selectedShellPath: String? private var outputDecoder = StreamingUTF8Decoder() + private var launchTask: Task? + private var launchGeneration = UUID() public init(transport: any TerminalTransport, preferredToolTabTitle: String? = nil) { self.transport = transport @@ -76,6 +78,23 @@ public final class TerminalSession: ObservableObject, Identifiable { selectedShellPath = shell shellName = URL(fileURLWithPath: shell).lastPathComponent let environment = terminalEnvironment() + if transport.requiresAsyncStart { + let generation = UUID() + launchGeneration = generation + launchTask = Task { [weak self] in + guard let self, !Task.isCancelled, launchGeneration == generation else { return } + do { + try await transport.startShell(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment) + guard !Task.isCancelled, launchGeneration == generation else { return } + isRunning = transport.isRunning; isReady = isRunning + } catch { + guard !Task.isCancelled, launchGeneration == generation else { return } + launchError = error.localizedDescription + isRunning = false; isReady = false; startedAt = nil; endedAt = Date() + } + } + return + } do { try transport.start(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment) isRunning = transport.isRunning; isReady = isRunning @@ -87,6 +106,44 @@ public final class TerminalSession: ObservableObject, Identifiable { @discardableResult public func startProcess(_ launch: TerminalProcessLaunch) throws -> Int32 { + prepareProcess(launch) + do { + let processID = try transport.startProcess( + launch, + environment: terminalEnvironment(applying: launch.environmentChanges) + ) + isRunning = transport.isRunning + isReady = isRunning + return processID + } catch { + failProcessStart(error) + throw error + } + } + + @discardableResult + public func startProcessAsync(_ launch: TerminalProcessLaunch) async throws -> Int32 { + prepareProcess(launch) + let generation = launchGeneration + do { + let processID = try await transport.startProcessAsync( + launch, + environment: terminalEnvironment(applying: launch.environmentChanges) + ) + guard !Task.isCancelled, launchGeneration == generation else { throw CancellationError() } + isRunning = transport.isRunning + isReady = isRunning + return processID + } catch { + if launchGeneration == generation { + transport.stop() + failProcessStart(error) + } + throw error + } + } + + private func prepareProcess(_ launch: TerminalProcessLaunch) { stop() let workingDirectory = URL( fileURLWithPath: launch.workingDirectory, @@ -103,23 +160,14 @@ public final class TerminalSession: ObservableObject, Identifiable { startedAt = Date() endedAt = nil shellName = URL(fileURLWithPath: launch.executablePath).lastPathComponent + } - do { - let processID = try transport.startProcess( - launch, - environment: terminalEnvironment(applying: launch.environmentChanges) - ) - isRunning = transport.isRunning - isReady = isRunning - return processID - } catch { - launchError = error.localizedDescription - isRunning = false - isReady = false - startedAt = nil - endedAt = Date() - throw error - } + private func failProcessStart(_ error: Error) { + launchError = error.localizedDescription + isRunning = false + isReady = false + startedAt = nil + endedAt = Date() } public func restart() { @@ -139,13 +187,21 @@ public final class TerminalSession: ObservableObject, Identifiable { public func clear() { transport.clear() } public func focus() { transport.focus() } public func stop() { + launchGeneration = UUID() + launchTask?.cancel() + launchTask = nil transport.stop() finishStop() } /// Stops the whole terminal session and waits for its processes to exit. public func stopAndWait() async { + launchGeneration = UUID() + let launch = launchTask + launchTask = nil + launch?.cancel() await transport.stopAndWait() + await launch?.value finishStop() } diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index f0b4be5c57..14690cb440 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -86,6 +86,38 @@ struct TerminalModuleTests { #expect(feature.terminalSessions.isEmpty) } + @Test + func workspaceShellDiscoveryRejectsStaleResultsAndDoesNotFallBackAfterFailure() async { + let first = URL(fileURLWithPath: "/fixture/first") + let second = URL(fileURLWithPath: "/fixture/second") + let gate = AsyncStream.makeStream() + var requested: [URL] = [] + let feature = TerminalFeatureModel(terminalFactory: { TestTransport() }, shellDiscovery: { ["/local/shell"] }, + workspaceShellDiscovery: { root in + requested.append(root) + if root == first { + var iterator = gate.stream.makeAsyncIterator() + _ = await iterator.next() + // Deliberately return a value after cancellation: the + // owning feature must reject the old host's completion. + return ["/first/shell"] + } + throw NSError(domain: "ShellFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: "fixture disconnected"]) + }) + defer { feature.stopAllSessions(); gate.continuation.finish() } + feature.setShellWorkspace(first) + #expect(feature.availableShells.isEmpty) + #expect(await shellDiscoveryEventually { requested == [first] }) + feature.setShellWorkspace(second) + #expect(await shellDiscoveryEventually { !feature.isDiscoveringShells }) + #expect(requested == [first, second]) + #expect(feature.availableShells.isEmpty) + #expect(feature.shellDiscoveryError == "fixture disconnected") + feature.setShellWorkspace(nil) + #expect(feature.availableShells == ["/local/shell"]) + #expect(feature.shellDiscoveryError == nil) + } + @Test func managedProcessLaunchPreservesArgumentsEnvironmentAndProcessID() throws { let transport = TestTransport() @@ -337,6 +369,16 @@ struct TerminalModuleTests { } } +@MainActor +private func shellDiscoveryEventually(_ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + @MainActor private final class TestTransport: TerminalTransport { let nativeView: AnyObject = NSObject() diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 720c256beb..df70612315 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -9,6 +9,165 @@ import Testing /// process a request reuses, and when the host stops or restarts it. @MainActor struct MacBuildProviderSessionsTests { + @Test + func preparedPluginKeepsConfigurationAndQueuedWorkspaceChanges() async throws { + let core = ScriptedProviderCore() + let preparing = TestGate() + let resume = TestGate() + let released = TestGate() + let root = URL(fileURLWithPath: "/workspace") + let configuration: ToolingJSONValue = .object(["fixture": .integer(7)]) + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/unused"), arguments: [], environment: [:], + prepareProcess: { _ in + preparing.open() + guard await resume.waitUntilOpen() else { throw CancellationError() } + return PreparedLanguageServerProcess(executableURL: URL(fileURLWithPath: "/host/bin/server"), + environment: [:], launch: .init(arguments: ["--remote"], initializationOptions: nil, + workspaceConfiguration: configuration), core: core, release: { released.open() }) + }, core: core, pluginID: "dev.example.fixture" + ) + defer { resume.open(); session.stop() } + try session.start(rootURL: root, workspaceFingerprint: nil) + try #require(await preparing.waitUntilOpen()) + let changes = [LanguageServerWorkspaceFileChange(fileURL: root.appendingPathComponent("new.swift"), kind: .created)] + try session.notifyWorkspaceFilesChanged(changes) + try session.synchronize(fileURL: root.appendingPathComponent("open.swift"), text: "latest", languageID: "swift") + resume.open() + try #require(await core.synchronized.waitUntilOpen()) + #expect(core.workspaceConfigurations == [configuration]) + #expect(core.workspaceChanges == changes) + #expect(core.startCalls.last?.arguments == ["--remote"]) + session.stop() + #expect(await released.waitUntilOpen()) + } + + @Test + func stoppedPreparationReleasesLatePackageWithoutLaunching() async throws { + let core = ScriptedProviderCore() + let preparing = TestGate() + let resume = TestGate() + let released = TestGate() + // Model a host allocation whose reply arrives after cancellation. + let lateReply = Task { @MainActor in await resume.waitUntilOpen() } + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/unused"), arguments: [], environment: [:], + prepareProcess: { _ in + preparing.open() + guard await lateReply.value else { throw CancellationError() } + return PreparedLanguageServerProcess(executableURL: URL(fileURLWithPath: "/host/bin/server"), + environment: [:], launch: .init(arguments: [], initializationOptions: nil), + core: core, release: { released.open() }) + }, core: core + ) + defer { resume.open(); lateReply.cancel(); session.stop() } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + try #require(await preparing.waitUntilOpen()) + session.stop() + resume.open() + #expect(await released.waitUntilOpen()) + #expect(core.startCalls.isEmpty) + #expect(!session.isRunning) + } + + @Test + func failedPreparedProcessReleasesItsPackage() async throws { + let core = ScriptedProviderCore(failStart: true) + let released = TestGate() + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/unused"), arguments: [], environment: [:], + prepareProcess: { _ in + PreparedLanguageServerProcess(executableURL: URL(fileURLWithPath: "/host/bin/server"), + environment: [:], launch: .init(arguments: [], initializationOptions: nil), + core: core, release: { released.open() }) + }, core: core + ) + defer { session.stop() } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + #expect(await released.waitUntilOpen()) + #expect(!session.isRunning) + } + + @Test + func remoteStartupReturnsBeforeTheHostRepliesAndFlushesLatestDocuments() async throws { + let core = ScriptedProviderCore(backgroundStart: true) + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/host/bin/server"), + arguments: [], environment: [:], core: core + ) + defer { core.releaseStartup.open(); session.stop() } + let root = URL(fileURLWithPath: "/workspace") + let file = root.appendingPathComponent("one.swift") + let closedFile = root.appendingPathComponent("closed.swift") + try session.start(rootURL: root, workspaceFingerprint: nil) + try #require(await core.started.waitUntilOpen()) + #expect(session.isRunning) + try session.synchronize(fileURL: file, text: "old", languageID: "swift") + try session.synchronize(fileURL: file, text: "latest", languageID: "swift", changes: []) + try session.synchronize(fileURL: closedFile, text: "closed", languageID: "swift") + session.closeDocument(closedFile) + #expect(core.syncedTexts.isEmpty) + + core.releaseStartup.open() + try #require(await core.synchronized.waitUntilOpen()) + #expect(core.syncedTexts == ["latest"]) + session.stop() + #expect(await core.destroyed.waitUntilOpen()) + } + + @Test + func stoppingRemoteStartupDestroysItsLateSessionWithoutPublishingReady() async throws { + let core = ScriptedProviderCore(backgroundStart: true) + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/host/bin/server"), + arguments: [], environment: [:], core: core + ) + defer { core.releaseStartup.open(); session.stop() } + var states: [LanguageServerSessionState] = [] + var releases = 0 + let resourcesReleased = TestGate() + session.onStateChange = { states.append($0) } + session.onReleased = { releases += 1 } + session.releaseResources = { + #expect(core.destroyed.isOpen, "Package bytes must outlive the late process") + resourcesReleased.open() + } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + try #require(await core.started.waitUntilOpen()) + session.stop() + #expect(!session.isRunning) + #expect(releases == 1) + #expect(!resourcesReleased.isOpen) + core.releaseStartup.open() + #expect(await core.destroyed.waitUntilOpen()) + #expect(await resourcesReleased.waitUntilOpen()) + #expect(states == [.startingProcess, .stopped]) + #expect(core.stopCalls.isEmpty) + } + + @Test + func releasingRemoteStartupOwnerStillDestroysTheLateSession() async throws { + let core = ScriptedProviderCore(backgroundStart: true) + var session: LanguageServerRuntimeSession? = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/host/bin/server"), + arguments: [], environment: [:], core: core + ) + defer { core.releaseStartup.open(); session?.stop() } + let resourcesReleased = TestGate() + session?.releaseResources = { + #expect(core.destroyed.isOpen) + resourcesReleased.open() + } + weak var owner = session + try session?.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + try #require(await core.started.waitUntilOpen()) + session = nil + #expect(owner == nil) + core.releaseStartup.open() + #expect(await core.destroyed.waitUntilOpen()) + #expect(await resourcesReleased.waitUntilOpen()) + } + /// A second request reuses the process; stopping the plugin's providers, /// which the plugin manager does before replacing its package, ends it /// and the next request starts a new one. @@ -436,12 +595,16 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked let functionStarted = TestGate() let functionCancelled = TestGate() let destroyed = TestGate() + let releaseStartup = TestGate() + let synchronized = TestGate() /// Opened when a `workspace.stuck` send is blocked; it stays blocked, as /// a write to a backend that stopped reading would, until `releaseStuckSend`. let stuckSendStarted = TestGate() private let stuckSendReleased = TestGate() private let initialState: String private let readyAfterStop: Bool + private let backgroundStart: Bool + private let failStart: Bool private let lock = NSLock() private var nextSession = 0 private var events: [String: [LanguageServerRuntimeEvent]] = [:] @@ -450,12 +613,26 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked private var _buildRequests: [String] = [] private var _buildParams: [ToolingJSONValue] = [] private var _functionRequests: [FunctionCall] = [] + private var _syncedTexts: [String] = [] + private var _workspaceConfigurations: [ToolingJSONValue?] = [] + private var _workspaceChanges: [LanguageServerWorkspaceFileChange] = [] - init(initialState: String = "ready", readyAfterStop: Bool = false) { + init(initialState: String = "ready", readyAfterStop: Bool = false, backgroundStart: Bool = false, failStart: Bool = false) { self.initialState = initialState self.readyAfterStop = readyAfterStop + self.backgroundStart = backgroundStart + self.failStart = failStart } + func requiresBackgroundLanguageServerStart(rootURL _: URL) -> Bool { backgroundStart } + var syncedTexts: [String] { lock.withLock { _syncedTexts } } + var workspaceConfigurations: [ToolingJSONValue?] { lock.withLock { _workspaceConfigurations } } + var workspaceChanges: [LanguageServerWorkspaceFileChange] { lock.withLock { _workspaceChanges } } + + func notifyLanguageServerWorkspaceFilesChanged(sessionID _: String, changes: [LanguageServerWorkspaceFileChange]) -> Result { + lock.withLock { _workspaceChanges.append(contentsOf: changes) } + return .success(()) + } var startCalls: [StartCall] { lock.withLock { _startCalls } } var stopCalls: [String] { lock.withLock { _stopCalls } } var buildRequests: [String] { lock.withLock { _buildRequests } } @@ -486,6 +663,13 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked workspaceFingerprint _: String?, mavenContext _: MavenLaunchContext?, initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, shutdownTimeout _: TimeInterval ) -> Result { + if failStart { return .failure(.init(code: "processFailed", message: "fixture startup failed")) } + if backgroundStart { + started.open() + guard releaseStartup.waitSynchronously() else { + return .failure(.init(code: "timed_out", message: "The test did not release startup.")) + } + } let result = lock.withLock { nextSession += 1 _startCalls.append(StartCall(arguments: arguments, pluginID: nil)) @@ -500,6 +684,18 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked /// Core queues the negotiated functions before the session turns ready. /// With `initialState: "initializing"` the session stays initializing /// until `becomeReady()`, or until a stop queues the late ready reply. + func startPluginLanguageServer( + pluginID: String, providerID: String, executableURL: URL, arguments: [String], + environment: [String: String], rootURL: URL, initializationOptions: ToolingJSONValue?, + workspaceConfiguration: ToolingJSONValue?, initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, shutdownTimeout: TimeInterval + ) -> Result { + lock.withLock { _workspaceConfigurations.append(workspaceConfiguration) } + return startPluginLanguageServer(pluginID: pluginID, providerID: providerID, executableURL: executableURL, + arguments: arguments, environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout) + } + func startPluginLanguageServer( pluginID: String, providerID _: String, executableURL _: URL, arguments: [String], environment: [String: String], rootURL _: URL, initializationOptions _: ToolingJSONValue?, @@ -631,9 +827,11 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked } func syncLanguageServerDocument( - sessionID _: String, fileURL _: URL, languageID _: String, text _: String + sessionID _: String, fileURL _: URL, languageID _: String, text: String ) -> Result { - .success(LanguageServerDocumentSync(documentVersion: 1, changed: false)) + lock.withLock { _syncedTexts.append(text) } + synchronized.open() + return .success(LanguageServerDocumentSync(documentVersion: 1, changed: false)) } func closeLanguageServerDocument(sessionID _: String, fileURL _: URL) {} diff --git a/macos/Tests/LitheTests/RemoteBuildExecutionTests.swift b/macos/Tests/LitheTests/RemoteBuildExecutionTests.swift new file mode 100644 index 0000000000..906d1b36ff --- /dev/null +++ b/macos/Tests/LitheTests/RemoteBuildExecutionTests.swift @@ -0,0 +1,156 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Remote build execution") +struct RemoteBuildExecutionTests { + private let root = URL(fileURLWithPath: "/fixture/build-placeholder") + + private func registry() -> RemoteWorkspaceRegistry { + let registry = RemoteWorkspaceRegistry() + registry.register(.init(root: root, remoteRoot: "/host/project", sessionID: "original", connectionID: UUID())) + return registry + } + + @Test + func mapsOnlyTypedPathsAndCapturesStdoutSeparately() async throws { + let host = BuildProcessFixture() + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry(), host: host) + let streamed = BuildOutputFixture() + let opaque = "--message=\(root.path)/keep-this-literal" + let result = try await runner.run(.init(executableURL: root.appendingPathComponent("tool"), + arguments: [opaque], workingDirectory: root.appendingPathComponent("module"), + environment: ["VALUE": root.path], capturesStandardOutput: true, timeout: .seconds(12))) { + streamed.append($0) + } + #expect(host.calls.first?.0 == RemoteProcessRequest(argv: ["/host/project/tool", opaque], + cwd: "/host/project/module", env: ["VALUE": root.path])) + #expect(host.calls.first?.1 == "original") + #expect(host.calls.first?.2 == .seconds(12)) + #expect(result.standardOutput == "model") + #expect(streamed.value == "warning") + #expect(result.termination == .exited(0)) + } + + @Test + func interleavedStreamsKeepTheirOwnPartialUTF8Codepoints() async throws { + let host = BuildProcessFixture() + host.events = [ + .output(Data([0xe4, 0xb8]), standardError: false), + .output(Data([0xe6, 0x96]), standardError: true), + .output(Data([0xad]), standardError: false), + .output(Data([0x87]), standardError: true) + ] + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry(), host: host) + let streamed = BuildOutputFixture() + _ = try await runner.run(.init(executableURL: URL(fileURLWithPath: "/host/bin/build"), + arguments: [], workingDirectory: root, environment: [:]), onOutput: { streamed.append($0) }) + #expect(streamed.value == "\u{4e2d}\u{6587}") + } + + @Test + func timeoutAndTransportFailureNeverRunLocally() async throws { + let host = BuildProcessFixture() + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry(), host: host) + let request = BuildCommandRequest(executableURL: URL(fileURLWithPath: "/host/bin/build"), + arguments: [], workingDirectory: root, environment: [:]) + host.failure = RemoteProbeFailure(code: "timed_out", message: "fixture") + #expect(try await runner.run(request, onOutput: { _ in }).termination == .timedOut) + host.failure = RemoteProbeFailure(code: "connection_closed", message: "fixture") + await #expect(throws: RemoteProbeFailure.self) { try await runner.run(request, onOutput: { _ in }) } + } + + @Test + func rejectsCrossHostExecutableAndPreservesSignalFailure() async throws { + let registry = registry() + registry.register(.init(root: URL(fileURLWithPath: "/fixture/second"), remoteRoot: "/second", + sessionID: "second", connectionID: UUID())) + let host = BuildProcessFixture() + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry, host: host) + let request = BuildCommandRequest(executableURL: URL(fileURLWithPath: "/fixture/second/tool"), + arguments: [], workingDirectory: root, environment: [:]) + await #expect(throws: CocoaError.self) { try await runner.run(request, onOutput: { _ in }) } + #expect(host.calls.isEmpty) + host.result = .init(exitCode: nil, signal: 15) + let signalled = try await runner.run(.init(executableURL: URL(fileURLWithPath: "/host/bin/build"), + arguments: [], workingDirectory: root, environment: [:]), onOutput: { _ in }) + #expect(signalled.termination == .exited(143)) + } + + @Test + func statePreparationRunsOffMainAndCleanupRetainsOriginalSession() async throws { + let registry = registry() + let host = BuildStateFixture() + let locator = MacRemoteBuildStateLocator(local: NoLocalBuildState(), registry: registry, host: host) + let resources = try await locator.prepareRun(pluginID: "fixture", buildSystemID: "build", + packageURL: URL(fileURLWithPath: "/local/plugin"), workspaceURL: root, allocatesOutputFile: true) + #expect(resources.workspaceStateDirectory.path == "/host/state") + #expect(resources.outputFile?.deletingLastPathComponent() == resources.workspaceStateDirectory) + #expect(resources.pluginDirectory == nil) + registry.register(.init(root: root, remoteRoot: "/host/project", sessionID: "replacement", connectionID: UUID())) + await resources.release() + #expect(host.preparedSession == "original") + #expect(host.removedSession == "original") + #expect(!host.usedMainThread) + } +} + +private struct NoLocalBuildRunner: BuildCommandRunning { + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws -> BuildCommandResult { + Issue.record("A remote build must never fall back to a local process") + throw CocoaError(.featureUnsupported) + } +} + +private struct NoLocalBuildState: BuildSystemStateLocating { + func workspaceStateDirectory(pluginID: String, buildSystemID: String, workspaceURL: URL) throws -> URL { + Issue.record("Remote state must not be allocated locally") + throw CocoaError(.featureUnsupported) + } + func allocateOutputFile(in workspaceStateDirectory: URL) throws -> URL { throw CocoaError(.featureUnsupported) } + func removeOutputFile(_ url: URL) { Issue.record("Remote output must not be removed locally") } +} + +private final class BuildProcessFixture: RemoteProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [(RemoteProcessRequest, String, Duration?)] = [] + var calls: [(RemoteProcessRequest, String, Duration?)] { lock.withLock { recorded } } + // Each test mutates the scripted outcome only between awaited invocations. + var failure: RemoteProbeFailure? + var result = RemoteProcessResult(exitCode: 0, signal: nil) + var events: [RemoteProcessEvent] = [.output(Data("model".utf8), standardError: false), .output(Data("warning".utf8), standardError: true)] + func runProcess(_ request: RemoteProcessRequest, session: String, timeout: Duration?, + onEvent: @escaping @Sendable (RemoteProcessEvent) -> Void) async throws -> RemoteProcessResult { + lock.withLock { recorded.append((request, session, timeout)) } + if let failure { throw failure } + events.forEach(onEvent) + return result + } +} + +private final class BuildOutputFixture: @unchecked Sendable { + private let lock = NSLock() + private var text = "" + func append(_ value: String) { lock.withLock { text += value } } + var value: String { lock.withLock { text } } +} + +private final class BuildStateFixture: RemoteBuildStateAccess, @unchecked Sendable { + private let lock = NSLock() + private var prepared: String? + private var removed: String? + private var mainThread = false + var preparedSession: String? { lock.withLock { prepared } } + var removedSession: String? { lock.withLock { removed } } + var usedMainThread: Bool { lock.withLock { mainThread } } + func prepareBuildState(pluginID: String, buildSystemID: String, workspacePath: String, session: String) throws -> String { + lock.withLock { prepared = session; mainThread = mainThread || Thread.isMainThread } + #expect(workspacePath == "/host/project") + return "/host/state" + } + func removeBuildOutputFile(_ path: String, session: String) throws { + lock.withLock { removed = session; mainThread = mainThread || Thread.isMainThread } + #expect(path.hasPrefix("/host/state/")) + } +} diff --git a/macos/Tests/LitheTests/RemoteConnectionsTests.swift b/macos/Tests/LitheTests/RemoteConnectionsTests.swift index abdaef450a..1c8d50146a 100644 --- a/macos/Tests/LitheTests/RemoteConnectionsTests.swift +++ b/macos/Tests/LitheTests/RemoteConnectionsTests.swift @@ -231,6 +231,7 @@ private final class FakeTransport: RemoteWorkspaceTransport, @unchecked Sendable var directories: [String: [RemoteDirectoryEntry]] = [:] private(set) var expectedVersions: [RemoteFileVersion?] = [] private var clock: Int64 = 1 + private(set) var mutations: [RemoteFileMutation] = [] func put(_ text: String, at path: String) { lock.withLock { @@ -269,6 +270,23 @@ private final class FakeTransport: RemoteWorkspaceTransport, @unchecked Sendable } } +extension FakeTransport { + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + try lock.withLock { + mutations.append(mutation) + switch mutation { + case .createDirectory(let path, _): directories[path] = [] + case .copy(let from, let to), .move(let from, let to): + guard files[to] == nil else { throw CocoaError(.fileWriteFileExists) } + guard let source = files[from] else { throw CocoaError(.fileNoSuchFile) } + files[to] = source + if case .move = mutation { files.removeValue(forKey: from) } + case .remove(let path), .trash(let path): files.removeValue(forKey: path) + } + } + } +} + /// Local operations that must not be reached for remote roots. private struct NoLocalWorkspace: WorkspaceOperations { func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { nil } @@ -319,6 +337,29 @@ struct RemoteWorkspaceRoutingTests { #expect(registry.resolve(URL(fileURLWithPath: "/tmp/lithe-tests/RemoteWorkspaces/c/h/application")) == nil) } + @Test + func remoteIndexLifecycleReachesTheHostAwareCoreAdapter() { + final class IndexOperations: WorkspaceOperations, @unchecked Sendable { + var calls: [String] = [] + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { nil } + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { calls.append("warm:" + rootURL.path) } + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) { + calls.append("update:" + changedPaths.joined(separator: ",")) + } + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { calls.append("invalidate:" + rootURL.path) } + } + let (_, _, transport, registry) = routing() + let core = IndexOperations() + let tree = RemoteRoutingWorkspaceOperations(local: core, registry: registry, transport: transport) + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) + tree.warmSearchIndex(at: root, visibilityRules: rules) + tree.updateSearchIndex(at: root, changedPaths: ["src/main.rs"], visibilityRules: rules) + tree.invalidateSearchIndex(at: root, visibilityRules: rules) + #expect(core.calls == ["warm:" + root.path, "update:src/main.rs", "invalidate:" + root.path]) + } + // Saving names the version that was read; a file changed on the host in // between is reported as a conflict with its current content. @Test @@ -381,21 +422,44 @@ struct RemoteWorkspaceRoutingTests { } @Test - func creatingRemoteFilesIsUnsupported() { + func creatingRemoteFilesAndSavingNewDocumentsNeverOverwritesExistingContent() throws { let (files, tree, transport, _) = routing() let url = root.appendingPathComponent("new.txt") + try files.createFile(at: url) + #expect(transport.text(at: "/srv/app/new.txt") == "") #expect(throws: CocoaError.self) { try files.createFile(at: url) } - #expect(throws: CocoaError.self) { try files.writeText("x", to: url) } - #expect(!tree.writeFile("x", at: root, relativePath: "new.txt")) - #expect(transport.expectedVersions.isEmpty) - #expect(transport.text(at: "/srv/app/new.txt") == nil) + try files.writeText("x", to: url) + #expect(transport.text(at: "/srv/app/new.txt") == "x") + #expect(tree.writeFile("tree", at: root, relativePath: "tree.txt")) + let saved = try files.writeDocumentText("new", to: root.appendingPathComponent("doc.txt"), expectedContent: nil) + if case .saved = saved {} else { Issue.record("New document was not created") } + let conflict = try files.writeDocumentText("overwrite", to: url, expectedContent: nil) + if case .conflict = conflict {} else { Issue.record("Existing file was overwritten") } + #expect(transport.text(at: "/srv/app/new.txt") == "x") } @Test - func unsupportedChangesToRemoteFilesFailInsteadOfTouchingTheDisk() { - let (files, _, _, _) = routing() - #expect(throws: CocoaError.self) { try files.removeItem(at: root.appendingPathComponent("a.txt")) } - #expect(throws: CocoaError.self) { try files.moveItem(at: root.appendingPathComponent("a"), to: root.appendingPathComponent("b")) } + func treeMutationsUseHostPathsAndRejectMixedHostsAndRootRemoval() throws { + let (files, _, transport, registry) = routing() + let a = root.appendingPathComponent("a.txt") + let b = root.appendingPathComponent("b.txt") + let c = root.appendingPathComponent("c.txt") + transport.put("content", at: "/srv/app/a.txt") + try files.createDirectory(at: root.appendingPathComponent("src"), withIntermediateDirectories: false) + try files.copyItem(at: a, to: b) + #expect(throws: CocoaError.self) { try files.copyItem(at: a, to: b) } + try files.moveItem(at: b, to: c) + try files.trashItem(at: c) + try files.removeItem(at: a) + #expect(transport.text(at: "/srv/app/a.txt") == nil) + #expect(transport.text(at: "/srv/app/c.txt") == nil) + #expect(transport.mutations.last == .remove(path: "/srv/app/a.txt")) + #expect(transport.mutations.contains(.trash(path: "/srv/app/c.txt"))) + #expect(throws: CocoaError.self) { try files.removeItem(at: root) } + #expect(throws: CocoaError.self) { try files.moveItem(at: a, to: URL(fileURLWithPath: "/tmp/local-target")) } + let other = URL(fileURLWithPath: "/tmp/lithe-tests/other") + registry.register(RemoteWorkspaceBinding(root: other, remoteRoot: "/other", sessionID: "other-host", connectionID: UUID())) + #expect(throws: CocoaError.self) { try files.copyItem(at: a, to: other.appendingPathComponent("a")) } } @Test diff --git a/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift b/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift new file mode 100644 index 0000000000..c1768bc65e --- /dev/null +++ b/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift @@ -0,0 +1,113 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Remote directory observation") @MainActor +struct RemoteDirectoryObservationTests { + @Test + func hostEventsMapToWorkspaceURLsAndCancelledGenerationsCannotRefresh() async throws { + let root = URL(fileURLWithPath: "/fixture/placeholder") + let registry = RemoteWorkspaceRegistry() + let binding = RemoteWorkspaceBinding(root: root, remoteRoot: "/host/project", sessionID: "one", connectionID: UUID()) + registry.register(binding) + let host = WatchFixture() + let batches = WatchBatches() + let source = RemoteDirectoryObservation( + configuration: DirectoryWatchConfiguration(workspaceRoot: root, gitContext: nil), + visibilityRules: FileVisibilityRules(hiddenDirectoryNames: [".git"], hiddenFilePatterns: []), + binding: binding, registry: registry, transport: host, onChange: batches.append + ) + defer { source.stop(); host.finish() } + source.start() + #expect(await watchEventually { host.active == 1 }) + #expect(host.requests == ["one:/host/project"]) + host.emit(.started) + #expect(batches.values.last?.requiresFullRescan == true) + host.emit(.changed(paths: ["/host/project/src/A.java", "/host/project-neighbor/a"], overflow: false)) + #expect(batches.values.last?.workspacePaths == [root.appendingPathComponent("src/A.java").path]) + host.emit(.changed(paths: [], overflow: true)) + #expect(batches.values.last?.requiresFullRescan == true) + host.emit(.invalidated(rootRemoved: true)) + #expect(batches.values.last?.watchRootsChanged == true) + let count = batches.values.count + source.stop() + // The fake deliberately retains its old callback after cancellation. + host.emit(.changed(paths: ["/host/project/stale"], overflow: false)) + #expect(batches.values.count == count) + #expect(await watchEventually { host.active == 0 }) + } + + @Test + func documentObservationsReportExternalChangesAndReleaseHostWatches() async throws { + let root = URL(fileURLWithPath: "/fixture/placeholder") + let registry = RemoteWorkspaceRegistry() + registry.register(RemoteWorkspaceBinding(root: root, remoteRoot: "/host/project", sessionID: "one", connectionID: UUID())) + let host = WatchFixture() + let files = RemoteRoutingFileOperations(local: MacWorkspaceFileOperations(), registry: registry, transport: host) + let changes = WatchDocuments() + let a = root.appendingPathComponent("src/A.java") + let b = root.appendingPathComponent("B.java") + let observation = files.observeDocuments(at: [a, b], onChange: changes.append) + defer { observation.cancel(); host.finish() } + #expect(await watchEventually { host.active == 1 }) + host.emit(.changed(paths: ["/host/project/src"], overflow: false)) + #expect(changes.values.last == [a]) + host.emit(.changed(paths: [], overflow: true)) + #expect(changes.values.last == [a, b]) + observation.cancel() + #expect(await watchEventually { host.active == 0 }) + } +} + +@MainActor private func watchEventually(_ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + +private final class WatchFixture: RemoteWorkspaceTransport, @unchecked Sendable { + private let lock = NSLock() + private var runs = 0 + private var recorded: [String] = [] + private var callback: (@Sendable (RemoteWatchEvent) -> Void)? + private var continuation: AsyncStream.Continuation? + var active: Int { lock.withLock { runs } } + var requests: [String] { lock.withLock { recorded } } + + func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + let (stream, continuation) = AsyncStream.makeStream() + lock.withLock { + runs += 1 + recorded.append(session + ":" + root) + callback = onEvent + self.continuation = continuation + } + defer { lock.withLock { runs -= 1; self.continuation = nil } } + var iterator = stream.makeAsyncIterator() + _ = await iterator.next() + } + + func emit(_ event: RemoteWatchEvent) { lock.withLock { callback }?(event) } + func finish() { lock.withLock { continuation }?.finish() } + func listDirectory(_ path: String, session id: String) throws -> [RemoteDirectoryEntry] { [] } + func readFile(_ path: String, session id: String) throws -> (data: Data, version: RemoteFileVersion) { throw CocoaError(.fileNoSuchFile) } + func writeFile(_ data: Data, to path: String, expected: RemoteFileVersion?, session id: String) throws -> RemoteWriteOutcome { .conflict } +} + +private final class WatchBatches: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [DirectoryChangeBatch] = [] + var values: [DirectoryChangeBatch] { lock.withLock { recorded } } + func append(_ batch: DirectoryChangeBatch) { lock.withLock { recorded.append(batch) } } +} + +private final class WatchDocuments: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [[URL]] = [] + var values: [[URL]] { lock.withLock { recorded } } + func append(_ urls: [URL]) { lock.withLock { recorded.append(urls) } } +} diff --git a/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift b/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift new file mode 100644 index 0000000000..6586222eae --- /dev/null +++ b/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift @@ -0,0 +1,166 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import LithePluginPackageSigning +import Testing +@testable import Lithe + +@Suite("Remote plugin deployment") +struct RemotePluginDeploymentTests { + @Test + func deploysApprovedBytesReadOnlyAndReleasesOriginalHost() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + let deployer = fixture.deployer(host) + let before = try PluginPackageSignature.contentDigest(packageAt: fixture.package.packageURL) + let result = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "original") + #expect(result.packageURL.path.hasPrefix("/host/.lithe-server/plugin-runs/")) + #expect(host.uploads.count == 2) + #expect(host.uploads.contains(where: { $0.mode == 0o500 && $0.target.hasSuffix("/server/run") })) + #expect(host.uploads.contains(where: { $0.mode == 0o400 && $0.target.hasSuffix("/plugin.json") })) + #expect(host.removals.isEmpty) + await result.release() + #expect(host.removals.count == 1) + #expect(host.removals.first?.session == "original") + #expect(try PluginPackageSignature.contentDigest(packageAt: fixture.package.packageURL) == before) + } + + @Test + func transferFailureAndCancellationRemoveOwnedSnapshot() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + host.failsUpload = true + await #expect(throws: CocoaError.self) { + _ = try await fixture.deployer(host).deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "failed") + } + #expect(host.removals.map(\.session) == ["failed"]) + let blocked = DeploymentHostFixture() + blocked.blocksUpload = true + let deployer = fixture.deployer(blocked) + let completed = TestGate() + let task = Task { + defer { completed.open() } + return try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "cancelled") + } + defer { task.cancel(); blocked.release.open() } + try #require(await blocked.started.waitUntilOpen(timeout: .seconds(5))) + task.cancel() + try #require(await completed.waitUntilOpen(timeout: .seconds(5))) + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + #expect(blocked.removals.map(\.session) == ["cancelled"]) + #expect(!blocked.cleanupWasCancelled) + } + + @Test + func changedApprovedPackageAndUnsupportedTargetNeverUpload() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + host.facts = .init(os: "linux", arch: "aarch64", homeDirectory: "/host", features: ["upload"]) + let deployer = fixture.deployer(host) + await #expect(throws: DeclarativePluginRejection.self) { + _ = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "linux") + } + #expect(host.uploads.isEmpty) + host.facts = DeploymentHostFixture.currentHost + try Data("changed".utf8).write(to: fixture.package.packageURL.appendingPathComponent("server/run")) + await #expect(throws: PluginPackageStoreError.self) { + _ = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "changed") + } + #expect(host.uploads.isEmpty) + #expect(host.removals.isEmpty) + } + + @Test + func hostArchitectureUsesHandshakeFacts() { + #expect(RemotePluginHost(os: "linux", arch: "aarch64", homeDirectory: nil, features: []).platform == "linux-arm64") + #expect(RemotePluginHost(os: "linux", arch: "aarch64", homeDirectory: nil, features: []).templateArchitecture == "arm64") + #expect(RemotePluginHost(os: "macos", arch: "x86_64", homeDirectory: nil, features: []).platform == "darwin-x64") + #expect(RemotePluginHost(os: "linux", arch: "unknown", homeDirectory: nil, features: []).platform == nil) + } +} + +private struct DeploymentFixture { + let root: URL + let package: InstalledPluginPackage + var id: String { package.descriptor.id.rawValue } + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-deploy-test-" + UUID().uuidString) + do { + let source = try DeclarativePluginTestSupport.writePackage(at: root.appendingPathComponent("source"), + manifest: DeclarativePluginTestSupport.manifestObject(version: .init(major: 1, minor: 0, patch: 0), + signatureRequirement: .unsigned, packagedLauncher: "server/run")) + let digest = try PluginPackageSignature.contentDigest(packageAt: source) + package = try MacPluginPackageStore(rootURL: root.appendingPathComponent("installed"), + validator: DeclarativePluginTestSupport.operations).installPackage(from: source, origin: .community, + disk: .init(fileName: "fixture.zip", archiveSHA256: nil, contentDigest: digest, approvedPrograms: [])) + } catch { try? FileManager.default.removeItem(at: root); throw error } + } + + func remove() { try? FileManager.default.removeItem(at: root) } + + func deployer(_ host: DeploymentHostFixture) -> MacRemotePluginPackageDeployer { + let root = root + return MacRemotePluginPackageDeployer(installed: [package], makeStore: { architecture in + MacPluginPackageStore(rootURL: root.appendingPathComponent("unused"), + validator: DeclarativePluginTestSupport.operations, architecture: architecture) + }, evaluator: RustPluginMarketplaceOperations(core: RustCoreBridge()), transport: NoDeploymentNetwork(), + host: host, temporaryDirectory: root) + } +} + +private struct NoDeploymentNetwork: MacPluginMarketplaceTransport { + func data(from url: URL, maximumBytes: Int64) async throws -> Data { + Issue.record("Disk approval must not acquire repository provenance") + throw CocoaError(.featureUnsupported) + } + func download(from url: URL, maximumBytes: Int64) async throws -> URL { + Issue.record("This test must not download packages") + throw CocoaError(.featureUnsupported) + } +} + +private final class DeploymentHostFixture: RemotePluginFileAccess, @unchecked Sendable { + struct Upload { let target: String; let mode: UInt32 } + struct Removal { let session: String } + static let currentHost = RemotePluginHost(os: "macos", + arch: MacPluginLanguageServerLaunchResolver.hostArchitecture == "arm64" ? "aarch64" : "x86_64", + homeDirectory: "/host", features: ["upload"]) + private let lock = NSLock() + // Scripted outcomes change only between awaited calls. + var facts = currentHost + var failsUpload = false + var blocksUpload = false + private var sent: [Upload] = [] + private var removed: [Removal] = [] + private var cancelledCleanup = false + let started = TestGate() + let release = TestGate() + var uploads: [Upload] { lock.withLock { sent } } + var removals: [Removal] { lock.withLock { removed } } + var cleanupWasCancelled: Bool { lock.withLock { cancelledCleanup } } + func pluginHost(session: String) async throws -> RemotePluginHost { facts } + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue { + if method == "fs.stat" { return .object(["kind": .string("directory"), "mode": .integer(0o700)]) } + if method == "fs.realPath" { return .object(["path": .string("/host/.lithe-server/plugin-runs")]) } + if method == "fs.remove" { + lock.withLock { removed.append(.init(session: session)); cancelledCleanup = Task.isCancelled } + } + return .null + } + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws { + #expect(try MacPluginPackageDownloader.sha256(of: source) == sha256) + lock.withLock { sent.append(.init(target: path, mode: mode)) } + if failsUpload { throw CocoaError(.fileWriteUnknown) } + if blocksUpload { + started.open() + _ = await release.waitUntilOpen(timeout: .seconds(5)) + try Task.checkCancellation() + } + } +} diff --git a/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift b/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift new file mode 100644 index 0000000000..8e8ab7e7b3 --- /dev/null +++ b/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift @@ -0,0 +1,161 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Remote runtime discovery") +struct RemoteRuntimeDiscoveryTests { + private static let binding = RemoteWorkspaceBinding( + root: URL(fileURLWithPath: "/fixture/remote-placeholder"), remoteRoot: "/host/project", + sessionID: "fixture-host", connectionID: UUID() + ) + + @Test + func discoversHostToolsAndNormalizesOnlyAgainstRemoteHome() throws { + let host = FixtureRuntimeHost() + let locator = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + let result = locator.discover() + #expect(result.javaRuntimes.map(\.homePath) == ["/host/jdk"]) + #expect(result.mavenRuntimes.map(\.version) == ["3.9.9"]) + #expect(locator.discoveryFailure == nil) + #expect(locator.normalizedPath("~/jdk", relativeTo: Self.binding.root) == "/host/home/jdk") + #expect(locator.normalizedPath("tools/java", relativeTo: Self.binding.root) == "/host/project/tools/java") + #expect(locator.normalizedPath(Self.binding.root.path + "/gradlew", relativeTo: nil) == "/host/project/gradlew") + #expect(locator.isExecutable(at: URL(fileURLWithPath: "/host/bin/node"))) + #expect(!locator.isExecutable(at: URL(fileURLWithPath: "/host/bin/not-executable"))) + #expect(!locator.isExecutable(at: URL(fileURLWithPath: "/host/bin/not-executable-link"))) + #expect(locator.validJavaHome(path: "/host/jdk")?.path == "/host/jdk") + #expect(locator.validJavaHome(path: "/fixture/local-jdk") == nil) + let calls = host.callCount + let selection = try #require(locator.selectJavaRuntime( + candidates: [AutomaticJavaCandidate(id: "old", version: "17", priority: 0), + AutomaticJavaCandidate(id: "current", version: "21", priority: 1)], fallbackID: "old" + )).get() + #expect(selection.id == "current") + #expect(host.callCount == calls) + } + + @Test + func failedAndCancelledDiscoveryCannotPublishPartialOrLocalTools() { + let host = FixtureRuntimeHost(failsVersion: true) + let locator = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + #expect(locator.discover().javaRuntimes.isEmpty) + #expect(locator.discoveryFailure != nil) + #expect(locator.environment().isEmpty) + #expect(locator.candidates(for: "node", projectURL: Self.binding.root, environment: ["PATH": "/host/bin"]).isEmpty) + let calls = host.callCount + #expect(locator.discover(isCancelled: { true }).javaRuntimes.isEmpty) + #expect(host.callCount == calls) + } + + @Test + func invalidatedProbeCannotOverwriteANewerSnapshot() { + let host = FixtureRuntimeHost() + let locator = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + host.beforeVersion = { locator.invalidateProbeCache() } + #expect(locator.discover().javaRuntimes.isEmpty) + #expect(locator.environment().isEmpty) + host.beforeVersion = nil + #expect(locator.discover().javaRuntimes.count == 1) + } + + @Test @MainActor + func projectSwitchUsesHostWorkersAndNewProjectAlwaysUsesLocalDiscovery() async { + let host = FixtureRuntimeHost() + let remote = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + let local = FixtureLocalRuntimeLocator() + let service = ProjectRuntimeService(runtimeLocator: local, store: FixtureRuntimeStore(), + workspaceRuntime: { $0 == Self.binding.root ? (remote, remote) : nil }) + defer { service.closeProject() } + service.openProject(at: Self.binding.root) + await service.ensureRuntimesDiscovered() + #expect(service.javaRuntimes.map(\.homePath) == ["/host/jdk"]) + #expect(service.javaHomeURL()?.path == "/host/jdk") + #expect(!host.probedOnMainThread) + let hostCalls = host.callCount + let newProject = await service.discoverJavaRuntimesForNewProject() + #expect(newProject.map(\.homePath) == ["/fixture/local-jdk"]) + #expect(host.callCount == hostCalls) + service.openProject(at: URL(fileURLWithPath: "/fixture/local-project")) + await service.ensureRuntimesDiscovered() + #expect(service.javaHomeURL()?.path == "/fixture/local-jdk") + #expect(host.callCount == hostCalls) + } +} + +private final class FixtureRuntimeHost: RemoteRuntimeProbing, @unchecked Sendable { + private let lock = NSLock() + private var calls = 0 + private var onMain = false + private var versionHook: (@Sendable () -> Void)? + let failsVersion: Bool + init(failsVersion: Bool = false) { self.failsVersion = failsVersion } + var callCount: Int { lock.withLock { calls } } + var probedOnMainThread: Bool { lock.withLock { onMain } } + var beforeVersion: (@Sendable () -> Void)? { + get { lock.withLock { versionHook } } + set { lock.withLock { versionHook = newValue } } + } + private func record(_ session: String) { + #expect(session == "fixture-host") + lock.withLock { calls += 1; onMain = onMain || Thread.isMainThread } + } + func runtimeEnvironment(session: String) throws -> [String: String] { + record(session) + return ["HOME": "/host/home", "JAVA_HOME": "/host/jdk", "PATH": "/host/bin"] + } + func runtimeRequirements(_ root: String, session: String) throws -> ToolingJSONValue { + record(session) + #expect(root == "/host/project") + return .object(["version": .integer(1), "toolchains": .object([ + "project-jdk": .object(["type": .string("java"), "minimumVersion": .string("21")]) + ])]) + } + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] { + record(session) + guard path == "/host/bin" else { return [] } + return [ + RemoteDirectoryEntry(name: "java", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o755)), + RemoteDirectoryEntry(name: "mvn", metadata: .init(kind: "file", mode: 0o755)), + RemoteDirectoryEntry(name: "node", metadata: .init(kind: "file", mode: 0o755)), + RemoteDirectoryEntry(name: "not-executable", metadata: .init(kind: "file", mode: 0o644)), + RemoteDirectoryEntry(name: "not-executable-link", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o644)) + ] + } + func runtimeRealPath(_ path: String, session: String) throws -> String { + record(session) + return path == "/host/bin/java" ? "/host/jdk/bin/java" : path + } + func runtimeExecutable(_ path: String, session: String) throws -> Bool { + record(session) + return ["/host/jdk/bin/java", "/host/project/gradlew"].contains(path) + } + func runtimeVersion(_ executable: String, session: String) throws -> String { + record(session) + beforeVersion?() + if failsVersion { throw RemoteProbeFailure(code: "connection_closed", message: "fixture disconnected") } + return executable.hasSuffix("/java") ? "openjdk version \"21.0.1\"\nOpenJDK Runtime Environment" : "Apache Maven 3.9.9" + } +} + +private struct FixtureRuntimeStore: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} +} + +private struct FixtureLocalRuntimeLocator: RuntimeLocator { + private let home = URL(fileURLWithPath: "/fixture/local-jdk") + func environment() -> [String: String] { [:] } + func discover() -> RuntimeDiscoveryResult { + RuntimeDiscoveryResult(javaRuntimes: [JavaRuntimeCandidate(homePath: home.path, version: "21", vendor: "fixture")], mavenRuntimes: []) + } + func validJavaHome(path: String) -> URL? { path == home.path ? home : nil } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { discover().javaRuntimes.first } + func isExecutable(at url: URL) -> Bool { false } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } +} diff --git a/macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift b/macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift new file mode 100644 index 0000000000..e0a6a1e4ea --- /dev/null +++ b/macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Remote shell discovery") +struct RemoteShellDiscoveryTests { + @Test + func usesOnlyHostPathsAndExecutablePermissionsInStableOrder() throws { + let host = ShellHost() + let shells = try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { false }) + #expect(shells == ["/host/bin/fish", "/host/bin/bash", "/host/bin/sh"]) + #expect(host.directories == ["/host/bin"]) + #expect(host.probedExecutables == ["/host/bin/sh"]) + #expect(host.sessions.allSatisfy { $0 == "host-one" }) + } + + @Test + func discoveryFailureDoesNotReturnAPartialHostSnapshot() { + let host = ShellHost(failsDirectory: true) + #expect(throws: (any Error).self) { + try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { false }) + } + } + + @Test + func cancellationStopsBeforeAnyHostRequest() { + let host = ShellHost() + #expect(throws: CancellationError.self) { + try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { true }) + } + #expect(host.sessions.isEmpty) + } + + @Test + func emptyHostEnvironmentDoesNotSearchMacSystemDirectories() throws { + let host = ShellHost(emptyEnvironment: true) + #expect(try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { false }).isEmpty) + #expect(host.directories.isEmpty) + } +} + +/// Used synchronously on the test's thread; the async worker's host is tested +/// through its protocol elsewhere. No real host or installed shell is required. +private final class ShellHost: RemoteShellProbing, @unchecked Sendable { + var directories: [String] = [] + var probedExecutables: [String] = [] + var sessions: [String] = [] + let failsDirectory: Bool + let emptyEnvironment: Bool + init(failsDirectory: Bool = false, emptyEnvironment: Bool = false) { + self.failsDirectory = failsDirectory + self.emptyEnvironment = emptyEnvironment + } + func shellEnvironment(session: String) throws -> [String: String] { + sessions.append(session) + return emptyEnvironment ? [:] : ["SHELL": "/host/bin/fish", "PATH": "/host/bin:/host/bin:relative"] + } + func registeredShells(session: String) throws -> String { + sessions.append(session) + return emptyEnvironment ? "" : "# host shells\n/host/bin/fish\n/host/bin/bash\n/host/bin/nu\nrelative" + } + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] { + sessions.append(session) + directories.append(path) + if failsDirectory { throw RemoteProbeFailure(code: "connection_closed", message: "fixture disconnected") } + return [ + .init(name: "fish", metadata: .init(kind: "file", mode: 0o755)), + .init(name: "bash", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o755)), + .init(name: "nu", metadata: .init(kind: "file", mode: 0o644)), + .init(name: "sh", metadata: .init(kind: "symlink", symlinkTargetKind: "file")), + .init(name: "dash", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o644)), + .init(name: "zsh", metadata: .init(kind: "directory", mode: 0o755)), + ] + } + func runtimeExecutable(_ path: String, session: String) throws -> Bool { + sessions.append(session) + probedExecutables.append(path) + return path == "/host/bin/sh" + } +} diff --git a/macos/Tests/LitheTests/RemoteTerminalTransportTests.swift b/macos/Tests/LitheTests/RemoteTerminalTransportTests.swift new file mode 100644 index 0000000000..f1afe74633 --- /dev/null +++ b/macos/Tests/LitheTests/RemoteTerminalTransportTests.swift @@ -0,0 +1,202 @@ +import Foundation +import LitheTerminalModule +import SwiftTerm +import Testing +@testable import Lithe + +@Suite("Remote terminal transport") @MainActor +struct RemoteTerminalTransportTests { + private let root = URL(fileURLWithPath: "/fixture/placeholder") + + private func terminal(_ host: FixtureTerminalHost) -> (MacRemoteTerminalTransport, MacTerminalTransport) { + let renderer = MacTerminalTransport(scrollbackLines: 100) + let binding = RemoteWorkspaceBinding(root: root, remoteRoot: "/host/project", + sessionID: "host-one", connectionID: UUID()) + return (MacRemoteTerminalTransport(renderer: renderer, binding: binding, transport: host), renderer) + } + + @Test + func managedLaunchUsesTheWorkspaceHostAndKeepsArgumentsSeparate() async throws { + let host = FixtureTerminalHost() + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + let launch = TerminalProcessLaunch(title: "Run", executablePath: "/host/jdk/bin/java", + arguments: ["-cp", "/host/project/classes with spaces", "Main"], + workingDirectory: root.appendingPathComponent("module").path) + let pid = try await transport.startProcessAsync(launch, environment: ["TERM": "xterm-256color"]) + #expect(pid == 321) + #expect(renderer.view.process?.running != true) + #expect(renderer.view.hasRemoteProcess) + #expect(host.requests.first?.session == "host-one") + #expect(host.requests.first?.request.cwd == "/host/project/module") + #expect(host.requests.first?.request.argv == ["/host/jdk/bin/java", "-cp", "/host/project/classes with spaces", "Main"]) + try transport.send(Data("input\n".utf8)) + #expect(await terminalEventually { !host.writes.isEmpty }) + #expect(host.writes.first == Data("input\n".utf8)) + await transport.stopAndWait() + #expect(host.activeRuns == 0) + #expect(!renderer.view.hasRemoteProcess) + } + + @Test + func shellUsesRemoteDefaultAndCoalescesResizeWhileControlIsPending() async throws { + let host = FixtureTerminalHost(holdsWrites: true) + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + try await transport.startShell(workingDirectory: root.path, shellPath: transport.defaultShellPath(), environment: [:]) + #expect(host.requests.first?.request.argv == nil) + #expect(host.requests.first?.request.cwd == "/host/project") + try transport.send(Data([1])) + #expect(await terminalEventually { !host.writes.isEmpty }) + let previous = host.sizes.count + for column in 40...140 { + renderer.view.terminalDelegate?.sizeChanged(source: renderer.view, newCols: column, newRows: 50) + } + #expect(host.sizes.count == previous) + host.releaseWrite() + #expect(await terminalEventually { host.sizes.count > previous }) + #expect(host.sizes.count == previous + 1) + #expect(host.sizes.last?.columns == 140) + #expect(host.sizes.last?.rows == 50) + await transport.stopAndWait() + #expect(host.activeRuns == 0) + } + + @Test + func stoppingBeforeAcknowledgementDoesNotPublishTheManagedSession() async throws { + let host = FixtureTerminalHost(acknowledgesStart: false) + let (transport, _) = terminal(host) + let feature = TerminalFeatureModel(terminalFactory: { transport }, workspaceTerminalFactory: { _ in transport }) + defer { feature.stopAllSessions(); host.finish() } + let started = Task { @MainActor in + try await feature.createProcessSessionAsync( + TerminalProcessLaunch(title: nil, executablePath: "/host/bin/tool", arguments: [], workingDirectory: "/host/project"), + workspaceURL: root) + } + #expect(await terminalEventually { host.activeRuns == 1 }) + #expect(feature.hasActiveSessions) + await feature.stopAllSessionsAndWait() + do { + _ = try await started.value + Issue.record("A stopped launch must not publish a terminal") + } catch is CancellationError {} catch { Issue.record("Unexpected launch failure: \(error)") } + #expect(feature.terminalSessions.isEmpty) + #expect(!feature.hasActiveSessions) + #expect(host.activeRuns == 0) + } + + @Test + func exitedTerminalReleasesItsRemoteSurfaceAndNotifiesOnce() async throws { + let host = FixtureTerminalHost() + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + var exits: [Int32?] = [] + transport.onTermination = { exits.append($0) } + try await transport.startShell(workingDirectory: root.path, shellPath: "/host/bin/fish", environment: [:]) + #expect(host.requests.first?.request.argv == ["/host/bin/fish", "-l", "-i"]) + host.finish(exit: 7) + #expect(await terminalEventually { host.activeRuns == 0 }) + #expect(!transport.isRunning) + #expect(transport.processID == nil) + #expect(!renderer.view.hasRemoteProcess) + #expect(exits.count == 1) + #expect(exits.first == 7) + await transport.stopAndWait() + #expect(exits.count == 1) + } + + @Test + func endedConnectionReleasesTerminalIdentityWithoutAnExitEvent() async throws { + let host = FixtureTerminalHost(holdsWrites: true) + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + var exits: [Int32?] = [] + transport.onTermination = { exits.append($0) } + try await transport.startShell(workingDirectory: root.path, shellPath: transport.defaultShellPath(), environment: [:]) + try transport.send(Data([1])) + #expect(await terminalEventually { !host.writes.isEmpty }) + + // The connection finishes without terminal.exit while an input control + // is pending. Neither its PID nor that control may survive teardown. + host.finish() + #expect(await terminalEventually { exits.count == 1 }) + #expect(exits.first == .some(nil)) + #expect(!transport.isRunning) + #expect(transport.processID == nil) + #expect(!renderer.view.hasRemoteProcess) + await transport.stopAndWait() + #expect(host.activeRuns == 0) + #expect(exits.count == 1) + } +} + +@MainActor +private func terminalEventually(_ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + +private final class FixtureTerminalHost: RemoteTerminalConnecting, @unchecked Sendable { + struct Request { let session: String; let request: RemoteTerminalRequest } + struct Size { let columns: UInt16; let rows: UInt16 } + private let lock = NSLock() + private var recordedRequests: [Request] = [] + private var recordedWrites: [Data] = [] + private var recordedSizes: [Size] = [] + private var runs = 0 + private var observer: (@Sendable (RemoteTerminalEvent) -> Void)? + private var end: AsyncStream.Continuation? + private let writeStream: AsyncStream + private let writeContinuation: AsyncStream.Continuation + let acknowledgesStart: Bool + let holdsWrites: Bool + var requests: [Request] { lock.withLock { recordedRequests } } + var writes: [Data] { lock.withLock { recordedWrites } } + var sizes: [Size] { lock.withLock { recordedSizes } } + var activeRuns: Int { lock.withLock { runs } } + + init(acknowledgesStart: Bool = true, holdsWrites: Bool = false) { + self.acknowledgesStart = acknowledgesStart + self.holdsWrites = holdsWrites + (writeStream, writeContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + } + + func runTerminal(_ request: RemoteTerminalRequest, session: String, + onEvent: @escaping @Sendable (RemoteTerminalEvent) -> Void) async -> Result { + let (stream, continuation) = AsyncStream.makeStream() + lock.withLock { + recordedRequests.append(Request(session: session, request: request)) + runs += 1 + observer = onEvent + end = continuation + } + defer { lock.withLock { runs -= 1; observer = nil; end = nil } } + if acknowledgesStart { onEvent(.started(id: 17, pid: 321)) } + var iterator = stream.makeAsyncIterator() + _ = await iterator.next() + return .success(()) + } + func writeTerminal(_ data: Data, id: UInt64, session: String) async throws { + #expect(id == 17 && session == "host-one") + lock.withLock { recordedWrites.append(data) } + if holdsWrites { + var iterator = writeStream.makeAsyncIterator() + _ = await iterator.next() + } + } + func resizeTerminal(id: UInt64, columns: UInt16, rows: UInt16, session: String) async throws { + #expect(id == 17 && session == "host-one") + lock.withLock { recordedSizes.append(Size(columns: columns, rows: rows)) } + } + func releaseWrite() { writeContinuation.yield() } + func finish(exit: Int32? = nil) { + let (observer, continuation) = lock.withLock { (observer, end) } + if let exit { observer?(.exited(exit)) } + continuation?.finish() + writeContinuation.finish() + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d15dd23d97..eaa3eeba14 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1380,7 +1380,7 @@ dependencies = [ "nix", "tokio", "tracing", - "windows", + "windows 0.52.0", ] [[package]] @@ -1482,6 +1482,7 @@ dependencies = [ "lithe-ide-host", "lithe-remote-client", "lithe-remote-protocol", + "lithe-remote-server", "quick-xml", "rand 0.8.7", "regex", @@ -1556,6 +1557,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "trash", ] [[package]] @@ -1719,6 +1721,31 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3068,6 +3095,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "5.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.62.2", +] + [[package]] name = "tree-sitter" version = "0.25.10" @@ -3184,6 +3229,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3390,6 +3441,27 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -3412,6 +3484,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3440,6 +3523,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3492,6 +3585,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index d331e6eb4f..e8f9602ec4 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -35,3 +35,6 @@ tree-sitter-java = "0.23" serde_yaml_ng = "0.10.0" zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" + +[target.'cfg(unix)'.dev-dependencies] +lithe-remote-server = { path = "../lithe-remote-server" } diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index e8d7181236..296f20fa24 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -32,6 +32,9 @@ struct BuildPayload { manifest: Value, #[serde(rename = "buildSystemID")] build_system_id: String, + /// Routing identity for plans that otherwise contain only relative paths. + #[serde(default, rename = "workspaceRoot")] + _workspace_root: Option, request: T, } diff --git a/rust/lithe-core/src/build/decode.rs b/rust/lithe-core/src/build/decode.rs index b20c1174c5..1ce589a637 100644 --- a/rust/lithe-core/src/build/decode.rs +++ b/rust/lithe-core/src/build/decode.rs @@ -7,8 +7,8 @@ //! outside the workspace. use crate::plugins::declarative::{PluginRejection, Validation}; +use crate::remote::{workspace, workspace_io as fs}; use serde_json::Value; -use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::UNIX_EPOCH; @@ -108,10 +108,26 @@ impl Workspace { )); } let mut roots = vec![slashed(root)]; - if let Ok(canonical) = path.canonicalize() { + let resolved = fs::canonicalize(&path); + if workspace::is_remote() && resolved.is_err() { + return Err(invalid_output( + "The workspace host could not resolve the build root.", + )); + } + if let Ok(canonical) = &resolved { roots.push(slashed(&canonical.to_string_lossy())); } - let canonical = path.canonicalize().unwrap_or(path); + let canonical = resolved.unwrap_or(path); + if workspace::is_remote() { + // Tool output contains host paths, while file adapters retain the + // native placeholder identity for containment and navigation. + roots.push(slashed( + &workspace::remote_path(&canonical).to_string_lossy(), + )); + roots.push(slashed( + &workspace::remote_path(Path::new(root)).to_string_lossy(), + )); + } Ok(Self { roots, path: canonical, @@ -129,7 +145,7 @@ impl Workspace { /// Resolves an existing workspace path without following links outside it. pub(crate) fn read_path(&self, relative: &str) -> Option { - let path = self.join(relative).canonicalize().ok()?; + let path = fs::canonicalize(self.join(relative)).ok()?; path.starts_with(&self.path).then_some(path) } @@ -161,8 +177,13 @@ impl Workspace { /// after checking existence and resolves links again before navigating. pub(crate) fn relativize_reported(&self, raw: &str, base: &str) -> Option { let relative = self.relativize(raw, base)?; - match self.join(&relative).canonicalize() { + match fs::canonicalize(self.join(&relative)) { Ok(resolved) if !resolved.starts_with(&self.path) => None, + Err(error) + if workspace::is_remote() && error.kind() != std::io::ErrorKind::NotFound => + { + None + } _ => Some(relative), } } diff --git a/rust/lithe-core/src/build/integrations/maven.rs b/rust/lithe-core/src/build/integrations/maven.rs index b29560e124..5128469ee3 100644 --- a/rust/lithe-core/src/build/integrations/maven.rs +++ b/rust/lithe-core/src/build/integrations/maven.rs @@ -232,7 +232,7 @@ fn split_failure_name(name: &str) -> (String, String) { pub(super) fn test_results( workspace: &Workspace, - _root: &str, + root: &str, project: &str, request: &DecodeTestResultsRequest, ) -> Validation { @@ -250,8 +250,36 @@ pub(super) fn test_results( reports, }) .map_err(rejected)?; - let mut cases: Vec = response - .test_cases + let mut reported_cases = response.test_cases; + if let Some(not_before_millis) = request.not_before_millis { + // Reactor runs write reports in each module, never just the aggregator. + // Keep text parsing once while reusing Maven's report locations per module. + for directory in super::super::outputs::report_directories(root, project, request)? { + if directory == project { + continue; + } + let module = maven_test_results(MavenTestResultsRequest { + root: workspace.path.to_string_lossy().into_owned(), + output: String::new(), + reports: Some(MavenTestReportsRequest { + module: Some(directory), + source_path: None, + classes: request.suite.iter().cloned().collect(), + not_before_millis, + }), + }) + .map_err(rejected)?; + if reported_cases.len() + module.test_cases.len() + > super::super::outputs::MAX_TEST_CASES + { + return Err(decode::invalid_output( + "The reactor has too many test cases.", + )); + } + reported_cases.extend(module.test_cases); + } + } + let mut cases: Vec = reported_cases .into_iter() .map(|case| TestCaseResult { suite: case.class_name, @@ -293,14 +321,12 @@ pub(super) fn test_results( }), } } - Ok(TestResults { - cases, - summary: Some(TestSummary { - total: response.tests_run, - passed: response.passed, - failed: response.failures, - errors: response.errors, - skipped: response.skipped, - }), - }) + let summary = (response.tests_run > 0 || cases.is_empty()).then_some(TestSummary { + total: response.tests_run, + passed: response.passed, + failed: response.failures, + errors: response.errors, + skipped: response.skipped, + }); + Ok(TestResults { cases, summary }) } diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 4fddc96485..0c4c964053 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -114,11 +114,17 @@ pub(crate) fn invocation_plan( manifest: &DeclarativePluginManifest, ) -> Validation { let root = decode::request_path(&request.root, "root")?; + let platform = crate::remote::workspace::current() + .map(|host| match host.connection.session.hello().host.os { + lithe_remote_protocol::methods::OperatingSystem::Linux => Platform::Linux, + lithe_remote_protocol::methods::OperatingSystem::Macos => Platform::Macos, + }) + .unwrap_or(request.platform); let invocation = &build_system.invocation; let mut executables = candidates( &invocation.executables, &root, - request.platform, + platform, manifest, &request.settings, )?; @@ -143,7 +149,7 @@ pub(crate) fn invocation_plan( "runtimeDependencies": invocation .runtime_dependencies .iter() - .map(|dependency| runtime(dependency, &root, request.platform, manifest, &request.settings)) + .map(|dependency| runtime(dependency, &root, platform, manifest, &request.settings)) .collect::>>()?, "version": invocation.version.as_ref().map(|probe| json!({ "arguments": probe.arguments })), "minimumVersion": invocation.minimum_version, diff --git a/rust/lithe-core/src/build/language_import.rs b/rust/lithe-core/src/build/language_import.rs index 5d0afd8352..9b84b4dc6a 100644 --- a/rust/lithe-core/src/build/language_import.rs +++ b/rust/lithe-core/src/build/language_import.rs @@ -81,9 +81,9 @@ pub(crate) fn language_import( let mut gradle = Map::new(); gradle.insert( "wrapperPresent".into(), - json!(workspace - .read_path(&wrapper) - .is_some_and(|path| path.is_file())), + json!(workspace.read_path(&wrapper).is_some_and(|path| { + crate::remote::workspace_io::is_file(path).unwrap_or(false) + })), ); gradle.insert( "offline".into(), diff --git a/rust/lithe-core/src/build/model.rs b/rust/lithe-core/src/build/model.rs index 8e4fc2074a..b1891248da 100644 --- a/rust/lithe-core/src/build/model.rs +++ b/rust/lithe-core/src/build/model.rs @@ -544,8 +544,8 @@ fn when_file_holds(builder: &ModelBuilder<'_>, rule: &impl RuleMatch, directory: rule.when_file().is_none_or(|file| { builder .workspace - .join(&decode::join_relative(directory, file)) - .is_file() + .read_path(&decode::join_relative(directory, file)) + .is_some_and(|path| crate::remote::workspace_io::is_file(path).unwrap_or(false)) }) } diff --git a/rust/lithe-core/src/build/outputs.rs b/rust/lithe-core/src/build/outputs.rs index 4657b35e84..085f224498 100644 --- a/rust/lithe-core/src/build/outputs.rs +++ b/rust/lithe-core/src/build/outputs.rs @@ -27,7 +27,7 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -const MAX_TEST_CASES: usize = 10_000; +pub(super) const MAX_TEST_CASES: usize = 10_000; const MAX_DIAGNOSTICS: usize = 10_000; const MAX_DEPENDENCIES: usize = 10_000; const MAX_DEPENDENCY_DEPTH: usize = 64; @@ -171,7 +171,11 @@ pub(crate) fn source_marker( .markers .iter() .map(|marker| join_relative(root, marker)) - .find(|path| workspace.read_path(path).is_some_and(|path| path.is_file())) + .find(|path| { + workspace + .read_path(path) + .is_some_and(|path| crate::remote::workspace_io::is_file(path).unwrap_or(false)) + }) } /// The model with the run entries derived from it. @@ -401,7 +405,7 @@ pub(crate) fn decode_test_results( /// Directories whose reports belong to the run: the selected project, or the /// root and every model project inside it, deepest first so a file two globs /// reach is read relative to its own project. -fn report_directories( +pub(super) fn report_directories( root: &str, directory: &str, request: &DecodeTestResultsRequest, diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 87910c5954..b8bea74a11 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -2823,16 +2823,19 @@ pub(super) fn read_requirements( .join(".lithe") .join("toolchains") .join("requirements.json"); - if !path.exists() { - return Ok(None); - } - let text = fs::read_to_string(path).map_err(|error| { - CoreError::new( - ErrorCode::PermissionDenied, - "Could not read toolchain requirements: .lithe/toolchains/requirements.json", - ) - .with_details(error.to_string()) - })?; + let text = match crate::remote::workspace_io::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err({ + CoreError::new( + ErrorCode::PermissionDenied, + "Could not read toolchain requirements: .lithe/toolchains/requirements.json", + ) + .with_details(error.to_string()) + }) + } + }; serde_json::from_str(&text).map(Some).map_err(|e| { CoreError::new( ErrorCode::ParseFailed, diff --git a/rust/lithe-core/src/execution/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs index b54db4b383..20bdf9d4df 100644 --- a/rust/lithe-core/src/execution/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -1,8 +1,8 @@ //! Bounded workspace traversal shared by all run-configuration detectors. use crate::protocol::{CoreError, ErrorCode}; +use crate::remote::workspace_io as fs; use std::collections::BTreeSet; -use std::fs; use std::path::{Path, PathBuf}; /// Detectors only care about manifests near the top of a project. Anything @@ -68,8 +68,11 @@ pub struct DirectoryContext { impl DirectoryContext { /// Captures the readable non-directory entries immediately below `path`. pub fn at(root: &Path, path: &Path) -> Result, CoreError> { - let Ok(entries) = fs::read_dir(path) else { - return Ok(None); + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(_) if !crate::remote::workspace::is_remote() => return Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), }; let files = entries .flatten() @@ -144,8 +147,13 @@ pub fn scan(root: &Path) -> Result, CoreError> { if results.len() >= MAX_DIRECTORIES { break; } - let Ok(entries) = fs::read_dir(&directory) else { - continue; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + // Local detection has historically skipped unreadable subtrees. + // A remote transport/permission error must not publish an empty model. + Err(_) if !crate::remote::workspace::is_remote() => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), }; let mut files = BTreeSet::new(); let mut children = Vec::new(); diff --git a/rust/lithe-core/src/execution/java_selection.rs b/rust/lithe-core/src/execution/java_selection.rs index 4997fdc964..c7370a2f65 100644 --- a/rust/lithe-core/src/execution/java_selection.rs +++ b/rust/lithe-core/src/execution/java_selection.rs @@ -3,8 +3,9 @@ use super::configuration::{ java_feature_version_parts, read_requirements, version_parts, version_satisfies, + ToolchainRequirementsDocument, }; -use crate::protocol::CoreError; +use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -26,6 +27,9 @@ pub struct JavaSelectionCandidate { pub struct JavaSelectionRequest { /// Open workspace; absent means no project requirements. pub root: Option, + /// Host-read snapshot for UI-safe selection; mutually exclusive with `root`. + /// The same document validation and selection policy apply to both sources. + pub requirements: Option, /// Usable executable homes probed by the platform. pub candidates: Vec, /// The platform's unconstrained choice; used when requirements are absent @@ -61,12 +65,18 @@ pub fn compare_java_candidates( /// Reuses Core's requirement reader and version semantics without generating /// requirements, probing executables, or changing an explicit user selection. pub fn select_java(request: JavaSelectionRequest) -> Result { - let document = request + if request.root.is_some() && request.requirements.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Java selection must use either a workspace or a requirements snapshot", + )); + } + let document = request.requirements.or(request .root .as_deref() .map(read_requirements) .transpose()? - .flatten(); + .flatten()); if let Some(document) = &document { super::configuration::validate_sidecar_version(document.version)?; } diff --git a/rust/lithe-core/src/git/configuration.rs b/rust/lithe-core/src/git/configuration.rs index 45ecb2597f..bfb4c74e9f 100644 --- a/rust/lithe-core/src/git/configuration.rs +++ b/rust/lithe-core/src/git/configuration.rs @@ -338,7 +338,13 @@ pub(crate) fn dispatch(payload: Value, save: bool) -> Result { /// can echo the effective Git before a project is open. The version probe is /// best effort: a binary that starts but cannot report a version still stays /// selected. -pub(crate) fn locate_executable() -> Value { +pub(crate) fn locate_executable() -> Result { + if let Some(host) = crate::remote::workspace::current() { + let params = + super::remote_process::parameters(&host, "/", &["--version".into()], false, true, &[])?; + return Ok(json!({"executable": params.argv.first(), + "version": host.connection.git_tool.lock().unwrap_or_else(|error| error.into_inner()).as_ref().map(|(_, version)| version.clone())})); + } let selected = execution_policy::current().executable; let resolved = lithe_git_host::configuration::executable(selected.as_deref()); let version = resolved.as_ref().and_then(|path| { @@ -346,10 +352,10 @@ pub(crate) fn locate_executable() -> Value { .ok() .map(|version| version.trim().to_owned()) }); - json!({ + Ok(json!({ "executable": resolved.map(|path| path.to_string_lossy().into_owned()), "version": version, - }) + })) } fn inspect(root: &str, scope: &str) -> Result { @@ -386,14 +392,13 @@ fn inspect(root: &str, scope: &str) -> Result { .unwrap_or_else(|| json!({"scope": "application", "origin": null})); fetch_sources.insert(field.into(), source); } - Ok( - json!({ "executable": lithe_git_host::configuration::executable(options.executable.as_deref()).map(|path| path.to_string_lossy().into_owned()), + let executable = locate_executable()?["executable"].clone(); + Ok(json!({ "executable": executable, "version": String::from_utf8_lossy(&version).trim(), "scope": scope, "entries": entries, "fields": fields, "temporaryConfig": execution_policy::temporary_config(), "fetchSources": fetch_sources, "fetchOptions": effective_fetch.as_ref().ok(), "fetchError": effective_fetch.as_ref().err(), "credentialHelperEnabled": options.use_credential_helper, - "interactiveAuthentication": options.interactive }), - ) + "interactiveAuthentication": options.interactive })) } #[cfg(test)] @@ -410,7 +415,7 @@ mod tests { ..Default::default() }; let _scope = execution_policy::Scope::begin(Some(options)).expect("valid policy"); - let result = locate_executable(); + let result = locate_executable().unwrap(); assert!(result["executable"].is_null()); assert!(result["version"].is_null()); } diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs index 027900a8d5..4d9e089576 100644 --- a/rust/lithe-core/src/git/execution_events.rs +++ b/rust/lithe-core/src/git/execution_events.rs @@ -191,6 +191,20 @@ impl Invocation { }) } pub(super) fn started(&self, root: &str, arguments: &[String]) { + let executable = lithe_git_host::configuration::executable( + super::execution_policy::current().executable.as_deref(), + ) + .map(|path| path.to_string_lossy().into_owned()); + self.started_on_host(root, arguments, executable); + } + + /// Reports the executable actually selected by a workspace host. + pub(super) fn started_on_host( + &self, + root: &str, + arguments: &[String], + executable: Option, + ) { let config = super::execution_policy::command_index(arguments) .is_some_and(|index| arguments[index] == "config"); let safe_config = arguments @@ -244,10 +258,7 @@ impl Invocation { &self.operation_id, Kind::Started { source: super::execution_policy::current().source, - executable: lithe_git_host::configuration::executable( - super::execution_policy::current().executable.as_deref(), - ) - .map(|path| path.to_string_lossy().into_owned()), + executable, temporary_config, display_arguments, global_arguments, diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 6c4f2852bb..d407d4c7b2 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -305,6 +305,7 @@ pub fn history_page(request: GitHistoryPageRequest) -> Result, reference: Option, /// Prevents a continuation from silently switching traversal policies. order: HistoryOrder, @@ -538,6 +545,27 @@ impl HistorySession { GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); + if let Some(remote) = super::remote_history::RemoteHistory::start(&root, &arguments)? { + return Ok(Self { + cursor: format!( + "git-history-cursor-{}", + NEXT_HISTORY_CURSOR.fetch_add(1, Ordering::Relaxed) + ), + host_key: crate::remote::workspace::identity_key(std::path::Path::new(&root)), + root, + reference, + order, + remote: Some(remote), + child: None, + receiver: None, + stdout_reader: None, + stderr_reader: None, + pending: None, + emitted: 0, + last_access: Instant::now(), + finished: false, + }); + } let mut process = git_process(); let mut child = process .args(&arguments) @@ -578,6 +606,8 @@ impl HistorySession { ); Ok(Self { cursor, + host_key: std::path::PathBuf::from(&root), + remote: None, root, reference, order, @@ -615,6 +645,15 @@ impl HistorySession { } fn next_commit(&mut self, deadline: Instant) -> Result, CoreError> { + if let Some(remote) = &mut self.remote { + while let Some(line) = remote.next_line(deadline)? { + if let Some(commit) = parse_commit(&line) { + return Ok(Some(commit)); + } + } + self.finish_process(false)?; + return Ok(None); + } loop { cancellation::check()?; if Instant::now() >= deadline { @@ -660,6 +699,7 @@ impl HistorySession { return Ok(()); } self.finished = true; + self.remote.take(); self.receiver.take(); let status = if let Some(mut child) = self.child.take() { if terminate { @@ -1121,6 +1161,8 @@ mod tests { fn finished_session(cursor: &str, last_access: Instant) -> HistorySession { HistorySession { + host_key: std::path::PathBuf::new(), + remote: None, cursor: cursor.to_string(), root: "/test/repository".to_string(), reference: None, diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index cf7439add0..c3e954e341 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -19,6 +19,8 @@ mod mutations; mod patch_exchange; mod progress; mod rebase_session; +mod remote_history; +mod remote_process; mod rewrite; mod setup; mod untracked_directories; @@ -62,11 +64,11 @@ use crate::protocol::{ GitWatchContextResponse, GitWorktreeResponse, GitWorktreesResponse, WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, }; +use crate::remote::workspace_io as fs; use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::collections::{HashSet, VecDeque}; use std::io::Read; -use std::io::Write; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; @@ -556,6 +558,20 @@ impl TemporaryGitCommitContext { } fn prepare_snapshot(head: Option<&str>) -> Result { + if let Some(directory) = + fs::remote_temp_directory("lithe-git-snapshot").map_err(line_history_io_error)? + { + let context = Self { + index_path: directory.join("index"), + directory, + temporary_reference: None, + }; + let contents = head + .map(|head| format!("{head}\n")) + .unwrap_or_else(|| "ref: refs/heads/lithe-snapshot\n".into()); + fs::write(context.directory.join("HEAD"), contents).map_err(line_history_io_error)?; + return Ok(context); + } // Creating even an ignored child inside .git changes its parent directory // on Windows. A read-only diff must not invalidate the Git watcher that // requested it; keep its disposable index outside repository metadata. @@ -566,16 +582,9 @@ impl TemporaryGitCommitContext { let (directory, sequence) = loop { let sequence = TEMPORARY_INDEX_SEQUENCE.fetch_add(1, Ordering::Relaxed); let candidate = parent.join(format!("{prefix}-{}-{sequence}", std::process::id())); - let mut builder = std::fs::DirBuilder::new(); - builder.recursive(false); // Public POSIX temp roots must not expose tracked paths/object IDs. // Set permissions at atomic creation, before writing HEAD or index. - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); - } - match builder.create(&candidate) { + match fs::create_dir_mode(&candidate, false, Some(0o700)) { Ok(()) => break (candidate, sequence), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => { @@ -608,7 +617,7 @@ impl TemporaryGitCommitContext { directory, temporary_reference, }; - std::fs::write(context.directory.join("HEAD"), head_contents).map_err(|error| { + fs::write(context.directory.join("HEAD"), head_contents).map_err(|error| { CoreError::new( ErrorCode::Unknown, "Could not initialize an isolated Git commit context", @@ -655,7 +664,7 @@ impl TemporaryGitCommitContext { impl Drop for TemporaryGitCommitContext { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.directory); + fs::cleanup_directory(&self.directory); } } @@ -902,7 +911,7 @@ pub fn workspace_repositories( continue; } - let entries = std::fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; + let entries = fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; let mut child_directories = Vec::new(); for entry in entries { crate::protocol::cancellation::check()?; @@ -1527,6 +1536,9 @@ fn write_with_trace(request: GitWriteRequest) -> Result { @@ -1748,6 +1760,16 @@ fn capture_git_process( ) -> Result { validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; crate::protocol::cancellation::check()?; + if let Some(result) = remote_process::capture( + root, + arguments, + input.as_deref(), + disable_optional_locks, + environment, + visible, + ) { + return result; + } let mut process = git_process(); let version = lithe_git_host::configuration::version(process.get_program(), || { crate::protocol::cancellation::check().is_err() @@ -3212,7 +3234,7 @@ fn git_directory(root: &str) -> Result, CoreError> { /// Reads a single-line numeric counter written by an in-progress rebase. fn rebase_counter(directory: &std::path::Path, name: &str) -> Option { - std::fs::read_to_string(directory.join(name)) + fs::read_to_string(directory.join(name)) .ok()? .trim() .parse() @@ -3239,22 +3261,26 @@ pub fn operation_state( if let Some(git_directory) = git_directory(&root)? { let rebase_merge = git_directory.join("rebase-merge"); let rebase_apply = git_directory.join("rebase-apply"); - let operation_directory = [rebase_merge, rebase_apply] - .into_iter() - .find(|path| path.exists()); + let operation_directory = if fs::try_exists(&rebase_merge)? { + Some(rebase_merge) + } else if fs::try_exists(&rebase_apply)? { + Some(rebase_apply) + } else { + None + }; if let Some(directory) = operation_directory { kind = "rebase".to_string(); step = rebase_counter(&directory, "msgnum"); total = rebase_counter(&directory, "end"); - reference = std::fs::read_to_string(directory.join("onto")) + reference = fs::read_to_string(directory.join("onto")) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - } else if git_directory.join("MERGE_HEAD").exists() { + } else if fs::try_exists(git_directory.join("MERGE_HEAD"))? { kind = "merge".to_string(); - } else if git_directory.join("CHERRY_PICK_HEAD").exists() { + } else if fs::try_exists(git_directory.join("CHERRY_PICK_HEAD"))? { kind = "cherryPick".to_string(); - } else if git_directory.join("REVERT_HEAD").exists() { + } else if fs::try_exists(git_directory.join("REVERT_HEAD"))? { kind = "revert".to_string(); } } @@ -3462,7 +3488,7 @@ struct ScopedLineHistoryDirectory(std::path::PathBuf); impl Drop for ScopedLineHistoryDirectory { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + fs::cleanup_directory(&self.0); } } @@ -3470,6 +3496,11 @@ impl Drop for ScopedLineHistoryDirectory { /// snapshot may hold private source text, so POSIX permissions are applied at /// atomic creation exactly like the Git commit context directories. fn create_line_history_scratch() -> Result { + if let Some(directory) = + fs::remote_temp_directory("lithe-line-history").map_err(line_history_io_error)? + { + return Ok(ScopedLineHistoryDirectory(directory)); + } let prefix = format!("lithe-line-history-{}", std::process::id()); let mut attempt = 0u128; loop { @@ -3481,7 +3512,7 @@ fn create_line_history_scratch() -> Result Result std::io::Result std::io::Result { validate_windows_git_path(path)?; - path.canonicalize().and_then(simplified_canonical_path) + fs::canonicalize(path).and_then(simplified_canonical_path) } /// Missing worktrees may still be listed, but invalid path identities never fall back. @@ -4233,7 +4264,7 @@ fn git_path_error(error: std::io::Error) -> CoreError { fn validate_root(raw_root: &str) -> Result { let root = canonicalize_simplified(Path::new(raw_root)).map_err(git_path_error)?; - if !root.is_dir() { + if !fs::is_dir(&root)? { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, "Workspace does not exist", @@ -4532,7 +4563,7 @@ fn append_git_ignore_patterns( } if let Some(parent) = target_path.parent() { - std::fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; + fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; } let mut appended = String::new(); if !existing.is_empty() && !existing.ends_with(b"\n") { @@ -4542,18 +4573,13 @@ fn append_git_ignore_patterns( appended.push_str(&pattern); appended.push('\n'); } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(target_path) - .map_err(|error| git_ignore_io_error("open", error))?; - file.write_all(appended.as_bytes()) + fs::append(&target_path, appended.as_bytes()) .map_err(|error| git_ignore_io_error("write", error))?; Ok(successful_git_result()) } fn read_git_ignore_bytes(path: &Path) -> Result, CoreError> { - match std::fs::read(path) { + match fs::read(path) { Ok(content) => Ok(content), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), Err(error) => Err(git_ignore_io_error("read", error)), @@ -4636,7 +4662,7 @@ fn git_resolved_path(root: &str, arguments: &[&str], label: &str) -> Result CoreError { @@ -6150,7 +6176,7 @@ fn worktree_paths_match(left: &str, right: &str) -> bool { if left_path == right_path { return true; } - match (left_path.canonicalize(), right_path.canonicalize()) { + match (fs::canonicalize(&left_path), fs::canonicalize(&right_path)) { (Ok(left), Ok(right)) => left == right, _ => false, } @@ -6300,6 +6326,9 @@ fn create_worktree(root: &str, request: &GitWriteRequest) -> Result Result arguments.push("unlock".into()), _ => unreachable!("caller restricts worktree mutations"), } + let destination = crate::remote::workspace::remote_path(Path::new(&destination)) + .to_string_lossy() + .into_owned(); arguments.extend(["--".into(), destination]); execute_git(root, &arguments, None) } @@ -7687,7 +7719,7 @@ pub fn watch_context( request: GitWatchContextRequest, ) -> Result, CoreError> { let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; - if !root.is_dir() { + if !fs::is_dir(&root)? { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, "Workspace does not exist", @@ -7859,7 +7891,7 @@ fn branch_requires_publish(root: &str, branch: &str) -> bool { /// Returns the normalized repository status and branch context. pub fn status(request: GitStatusRequest) -> Result { let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; - if !root.is_dir() { + if !fs::is_dir(&root)? { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, "Workspace does not exist", @@ -8042,6 +8074,34 @@ fn pathspec_batches(paths: &[String], budget: usize) -> Vec<&[String]> { } fn run_git(directory: &Path, arguments: &[&str]) -> Result { + if crate::remote::workspace::is_remote() { + let output = capture_git_process( + &directory.to_string_lossy(), + &arguments + .iter() + .map(|value| value.to_string()) + .collect::>(), + None, + true, + &[], + false, + )?; + #[cfg(unix)] + let status = { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(output.exit_code << 8) + }; + #[cfg(windows)] + let status = { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(output.exit_code as u32) + }; + return Ok(std::process::Output { + status, + stdout: output.stdout, + stderr: output.stderr, + }); + } // Status and path discovery are read-only from Lithe's point of view. Git // may otherwise refresh its optional index data while answering a query, // which emits `.git/index` events into the native watcher and can trigger diff --git a/rust/lithe-core/src/git/patch_exchange.rs b/rust/lithe-core/src/git/patch_exchange.rs index b45d8e97ff..e344511f52 100644 --- a/rust/lithe-core/src/git/patch_exchange.rs +++ b/rust/lithe-core/src/git/patch_exchange.rs @@ -5,6 +5,7 @@ use super::{ operation_state, repository_root, resolve_commit_revision, validate_root, CoreError, ErrorCode, GitCommandResponse, GitOperationStateRequest, TemporaryGitCommitContext, }; +use crate::remote::workspace_io as fs; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -304,7 +305,7 @@ fn export_unstaged_with_untracked( "GIT_INDEX_FILE".into(), context.index_path.to_string_lossy().into_owned(), )]; - match std::fs::copy(index, &context.index_path) { + match fs::copy(index, &context.index_path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let output = capture_git_with_environment( @@ -498,7 +499,7 @@ fn check_patch( &["rev-parse", "--path-format=absolute", "--git-path", "index"], "Git index", )?; - match std::fs::copy(index, &context.index_path) { + match fs::copy(index, &context.index_path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(patch_io_error(error)), @@ -699,7 +700,7 @@ fn state_token( // Some Git reads refresh stat data despite optional-lock suppression. Keep // every snapshot subprocess on a copy while hashing the real index bytes. let context = TemporaryGitCommitContext::prepare_snapshot(None)?; - match std::fs::copy(&index, &context.index_path) { + match fs::copy(&index, &context.index_path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(patch_io_error(error)), @@ -799,7 +800,7 @@ fn state_token( fn ensure_working_ancestors(root: &Path, path: &Path) -> Result<(), CoreError> { let mut parent = path.parent(); while let Some(directory) = parent.filter(|directory| *directory != root) { - match std::fs::symlink_metadata(directory) { + match fs::symlink_metadata(directory) { Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { return Err(invalid( "Patch paths cannot traverse symbolic links or files", @@ -815,7 +816,7 @@ fn ensure_working_ancestors(root: &Path, path: &Path) -> Result<(), CoreError> { } fn hash_optional_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { - match std::fs::symlink_metadata(path) { + match fs::symlink_metadata(path) { Ok(_) => { hash.update([1]); hash_working_file(hash, path) @@ -830,15 +831,14 @@ fn hash_optional_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { fn hash_working_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { use std::io::Read; - let metadata = std::fs::symlink_metadata(path).map_err(patch_io_error)?; + let metadata = fs::symlink_metadata(path).map_err(patch_io_error)?; #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - hash.update(metadata.permissions().mode().to_le_bytes()); + hash.update(metadata.mode().to_le_bytes()); } hash.update([u8::from(metadata.file_type().is_symlink())]); if metadata.file_type().is_symlink() { - let target = std::fs::read_link(path).map_err(patch_io_error)?; + let target = fs::read_link(path).map_err(patch_io_error)?; add_hash_field(hash, target.as_os_str().as_encoded_bytes()); return Ok(()); } @@ -848,7 +848,7 @@ fn hash_working_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { )); } hash.update(metadata.len().to_le_bytes()); - let mut file = std::fs::File::open(path).map_err(patch_io_error)?; + let mut file = fs::File::open(path).map_err(patch_io_error)?; let mut buffer = [0; 65536]; loop { crate::protocol::cancellation::check()?; diff --git a/rust/lithe-core/src/git/rebase_session.rs b/rust/lithe-core/src/git/rebase_session.rs index 90ebe4562d..8c35376618 100644 --- a/rust/lithe-core/src/git/rebase_session.rs +++ b/rust/lithe-core/src/git/rebase_session.rs @@ -2,8 +2,8 @@ use super::rewrite::{GitHistoryRewriteBlocker, GitHistoryRewriteCommit, RewriteLease}; use super::*; +use crate::remote::workspace_io as fs; use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, OpenOptions}; const SESSION_DIRECTORY: &str = "lithe-rebase-session"; const SESSION_MANIFEST: &str = "session.json"; @@ -356,14 +356,7 @@ fn ensure_owned_file(path: &Path) -> Result<(), CoreError> { fn write_file(path: &Path, bytes: &[u8]) -> Result<(), CoreError> { ensure_owned_file(path)?; - let mut file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(path) - .map_err(io_error)?; - file.write_all(bytes).map_err(io_error)?; - file.sync_all().map_err(io_error) + fs::write_synced(path, bytes).map_err(io_error) } // Status is the only persisted field that changes during a session. Reserve @@ -508,7 +501,7 @@ fn environment(root: &str, record: &SessionRecord) -> Result Result { let native = metadata_directory(root)?.join("rebase-merge"); - if !native.exists() { + if !fs::try_exists(&native).map_err(io_error)? { return Ok(false); } let read = |name: &str| { @@ -580,7 +573,7 @@ fn project_session( let status = if owned { if !operation.conflicted_paths.is_empty() { "conflict" - } else if native.join("amend").exists() + } else if fs::try_exists(native.join("amend")).map_err(io_error)? && done.last().is_some_and(|step| step.action == "edit") { "edit" diff --git a/rust/lithe-core/src/git/remote_history.rs b/rust/lithe-core/src/git/remote_history.rs new file mode 100644 index 0000000000..99c9b9793d --- /dev/null +++ b/rust/lithe-core/src/git/remote_history.rs @@ -0,0 +1,196 @@ +//! Incremental Git history on the workspace host, without local reader threads. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use crate::remote::workspace::{self, Host}; +use lithe_remote_client::session::Event; +use lithe_remote_protocol::methods::{self, OutputStream}; +use serde_json::json; +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +const POLL: Duration = Duration::from_millis(10); +const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024; +const MAX_LINE_BYTES: usize = 1024 * 1024; +const MAX_STDERR_BYTES: usize = 64 * 1024; + +/// A cursor owns both its remote process and subscription across page requests. +pub(super) struct RemoteHistory { + host: Host, + id: u64, + events: Option>, + early: VecDeque, + line: Vec, + output: VecDeque, + stderr: Vec, + exited: bool, +} + +impl RemoteHistory { + /// Starts the same Git log argv on the selected host, never a local fallback. + pub(super) fn start(root: &str, arguments: &[String]) -> Result, CoreError> { + let Some(host) = workspace::current() else { + return Ok(None); + }; + let params = super::remote_process::parameters(&host, root, arguments, false, true, &[])?; + let id = host.connection.session.allocate_id(); + let events = host.connection.session.subscribe("process", id); + let mut stream = Self { + host, + id, + events: Some(events), + early: VecDeque::new(), + line: Vec::new(), + output: VecDeque::new(), + stderr: Vec::new(), + exited: false, + }; + let mut params = serde_json::to_value(params).expect("spawn parameters encode"); + params["processId"] = json!(id); + let pending = stream + .host + .connection + .session + .begin_request_value(methods::PROCESS_SPAWN, params, Duration::from_secs(30)) + .map_err(failure)?; + let mut bytes = 0usize; + loop { + cancellation::check()?; + if pending.poll(POLL).map_err(failure)?.is_some() { + break; + } + // Drain early output so a full subscriber cannot hide the spawn reply. + while let Ok(event) = stream.events.as_ref().expect("subscribed").try_recv() { + bytes = bytes.saturating_add(event.params.to_string().len()); + if bytes > MAX_PENDING_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote Git startup output exceeded its limit", + )); + } + stream.early.push_back(event); + } + } + Ok(Some(stream)) + } + + /// Reads one complete UTF-8 log record within the owning page's deadline. + pub(super) fn next_line(&mut self, deadline: Instant) -> Result, CoreError> { + loop { + cancellation::check()?; + if Instant::now() >= deadline { + return Err(CoreError::new( + ErrorCode::TimedOut, + "Remote Git history page timed out", + )); + } + while let Some(byte) = self.output.pop_front() { + if byte == b'\n' { + return self.take_line().map(Some); + } + self.line.push(byte); + if self.line.len() > MAX_LINE_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git history record exceeded its limit", + )); + } + } + if self.exited { + return if self.line.is_empty() { + Ok(None) + } else { + self.take_line().map(Some) + }; + } + let event = if let Some(event) = self.early.pop_front() { + event + } else { + match self.events.as_ref().expect("subscribed").recv_timeout(POLL) { + Ok(event) => event, + Err(RecvTimeoutError::Timeout) if !self.host.connection.session.is_closed() => { + continue + } + Err(_) => { + return Err(CoreError::new( + ErrorCode::ConnectionClosed, + "Remote Git history connection closed", + )) + } + } + }; + match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => { + let output: methods::ProcessOutputEvent = event.decode().map_err(failure)?; + let bytes = methods::decode_bytes(&output.data).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git history bytes") + .with_details(error.to_string()) + })?; + if output.process_id != self.id + || bytes.len() > methods::MAX_TRANSFER_BYTES as usize + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote Git history output", + )); + } + if output.stream == OutputStream::Stdout { + self.output.extend(bytes); + } else { + let remaining = MAX_STDERR_BYTES.saturating_sub(self.stderr.len()); + self.stderr + .extend_from_slice(&bytes[..bytes.len().min(remaining)]); + } + } + methods::EVENT_PROCESS_EXIT => { + let exit: methods::ProcessExitEvent = event.decode().map_err(failure)?; + if exit.process_id != self.id + || exit.exit_code != Some(0) + || exit.signal.is_some() + { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git history failed") + .with_details( + String::from_utf8_lossy(&self.stderr).trim().to_owned(), + ), + ); + } + self.exited = true; + } + _ => { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Unexpected remote Git history event", + )) + } + } + } + } + + fn take_line(&mut self) -> Result { + if self.line.last() == Some(&b'\r') { + self.line.pop(); + } + String::from_utf8(std::mem::take(&mut self.line)).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Git history is not UTF-8") + .with_details(error.to_string()) + }) + } +} + +impl Drop for RemoteHistory { + fn drop(&mut self) { + self.events.take(); + self.host.connection.session.unsubscribe("process", self.id); + let _ = self.host.connection.session.request_value( + methods::PROCESS_KILL, + json!({"processId": self.id, "force": true}), + Duration::from_secs(2), + ); + } +} + +fn failure(error: lithe_remote_client::session::ClientError) -> CoreError { + CoreError::new(ErrorCode::ProcessFailed, "Remote Git history failed") + .with_details(error.to_string()) +} diff --git a/rust/lithe-core/src/git/remote_process.rs b/rust/lithe-core/src/git/remote_process.rs new file mode 100644 index 0000000000..c8a42ee348 --- /dev/null +++ b/rust/lithe-core/src/git/remote_process.rs @@ -0,0 +1,330 @@ +//! Executes the existing Git engine's invocations on its request-scoped host. + +use super::{execution_events, execution_policy, GitProcessOutput}; +use crate::protocol::{CoreError, ErrorCode}; +use crate::remote::{workspace, workspace_host}; +use lithe_remote_protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Duration; + +const MAX_CAPTURE_BYTES: usize = 32 * 1024 * 1024; + +/// Returns `None` only for a local workspace; remote failures never run local Git. +pub(super) fn capture( + root: &str, + arguments: &[String], + input: Option<&str>, + disable_optional_locks: bool, + environment: &[(String, String)], + visible: bool, +) -> Option> { + workspace::current().map(|host| { + let session = &host.connection.session; + let params = parameters( + &host, + root, + arguments, + input.is_some(), + disable_optional_locks, + environment, + )?; + let executable = params.argv.first().cloned(); + let mut invocation = visible + .then(execution_events::Invocation::current) + .flatten(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let result = workspace_host::process_observing( + session, + params, + input.map(str::as_bytes), + &mut |event| { + match event.event.as_str() { + "process.started" => { + if let Some(invocation) = &invocation { + invocation.started_on_host(root, arguments, executable.clone()); + } + } + methods::EVENT_PROCESS_OUTPUT => { + let event: methods::ProcessOutputEvent = + event.decode().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git output") + .with_details(error.to_string()) + })?; + let bytes = methods::decode_bytes(&event.data).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote Git output bytes", + ) + .with_details(error.to_string()) + })?; + let target = if event.stream == OutputStream::Stdout { + &mut stdout + } else { + &mut stderr + }; + if target.len() + bytes.len() > MAX_CAPTURE_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git output exceeded the capture limit", + )); + } + target.extend_from_slice(&bytes); + if let Some(invocation) = &mut invocation { + invocation.output( + if event.stream == OutputStream::Stdout { + lithe_git_host::Stream::Stdout + } else { + lithe_git_host::Stream::Stderr + }, + &bytes, + ); + } + } + _ => {} + } + Ok(()) + }, + ); + let exit = result + .as_ref() + .ok() + .and_then(|value| value["exitCode"].as_i64()) + .and_then(|code| i32::try_from(code).ok()); + if let Some(invocation) = invocation { + let expected = !visible + && exit == Some(1) + && result.is_ok() + && execution_policy::command_index(arguments) + .is_some_and(|index| arguments[index] == "config"); + invocation.finished_with_expected_exit(exit, result.as_ref().err(), expected); + } + result?; + Ok(GitProcessOutput { + stdout, + stderr, + exit_code: exit.unwrap_or(1), + }) + }) +} + +/// Builds host-native argv and environment without exporting client executable paths. +pub(super) fn parameters( + host: &workspace::Host, + root: &str, + arguments: &[String], + pipe_stdin: bool, + disable_optional_locks: bool, + environment: &[(String, String)], +) -> Result { + let result = workspace_host::request_cancellable( + &host.connection.session, + methods::ENVIRONMENT_RESOLVE, + serde_json::json!({}), + Duration::from_secs(60), + ); + crate::protocol::cancellation::check()?; + let resolved: methods::EnvironmentResolveResult = + serde_json::from_value(result.map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not resolve the remote login environment", + ) + .with_details(error.to_string()) + })?) + .map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote login environment") + .with_details(error.to_string()) + })?; + let mut env = BTreeMap::new(); + if disable_optional_locks { + env.insert("GIT_OPTIONAL_LOCKS".into(), "0".into()); + } + for (key, value) in environment { + // These paths are generated by the Git engine in workspace URL identity. + let value = if matches!( + key.as_str(), + "GIT_DIR" + | "GIT_COMMON_DIR" + | "GIT_WORK_TREE" + | "GIT_INDEX_FILE" + | "LITHE_REBASE_DIR" + | "LITHE_REBASE_TODO" + | "LITHE_REBASE_MESSAGES" + ) { + host.remote_path(Path::new(value)) + .to_string_lossy() + .into_owned() + } else { + value.clone() + }; + env.insert(key.clone(), value); + } + if execution_policy::command_index(arguments).is_some_and(|index| arguments[index] != "config") + { + let inherited = resolved + .environment + .get("GIT_CONFIG_COUNT") + .map(String::as_str) + .unwrap_or("0") + .parse::() + .ok() + .filter(|count| *count <= 4096) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote Git configuration count", + ) + })?; + let values = execution_policy::temporary_config(); + for (offset, (key, value)) in values.iter().enumerate() { + env.insert( + format!("GIT_CONFIG_KEY_{}", inherited + offset), + key.clone(), + ); + env.insert( + format!("GIT_CONFIG_VALUE_{}", inherited + offset), + value.clone(), + ); + } + env.insert( + "GIT_CONFIG_COUNT".into(), + (inherited + values.len()).to_string(), + ); + } + env.insert("GIT_PAGER".into(), "cat".into()); + env.insert("LC_ALL".into(), "C".into()); + env.insert("GIT_TERMINAL_PROMPT".into(), "0".into()); + // The remote credential helper/SSH agent owns authentication until the + // host AskPass relay is installed; never export the local helper path. + let executable = remote_git_executable(host, &resolved)?; + let mut argv = vec![executable]; + argv.extend_from_slice(arguments); + Ok(SpawnParams { + process_id: 0, + argv, + cwd: Some( + host.remote_path(Path::new(root)) + .to_string_lossy() + .into_owned(), + ), + environment: EnvironmentSource::Login, + env, + pipe_stdin, + }) +} + +fn remote_git_executable( + host: &workspace::Host, + environment: &methods::EnvironmentResolveResult, +) -> Result { + if let Some((executable, _)) = host + .connection + .git_tool + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + { + return Ok(executable.clone()); + } + let path = environment + .environment + .get("PATH") + .map(String::as_str) + .unwrap_or(""); + for directory in path + .split(':') + .filter(|directory| directory.starts_with('/')) + { + let candidate = format!("{}/git", directory.trim_end_matches('/')); + let result = workspace_host::request_cancellable( + &host.connection.session, + methods::FS_STAT, + serde_json::json!({"path": candidate}), + Duration::from_secs(30), + ); + crate::protocol::cancellation::check()?; + let metadata = match result { + Ok(value) => { + serde_json::from_value::(value).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git metadata") + .with_details(error.to_string()) + })? + } + Err(lithe_remote_client::session::ClientError::Remote(error)) + if error.code == lithe_remote_protocol::ErrorCode::NotFound => + { + continue + } + Err(error) => { + return Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not resolve remote Git", + ) + .with_details(error.to_string())) + } + }; + let regular = metadata.kind == methods::FileKind::File + || metadata.symlink_target_kind == Some(methods::FileKind::File); + if regular && (metadata.kind == methods::FileKind::Symlink || metadata.mode & 0o111 != 0) { + let mut version = Vec::new(); + let result = workspace_host::process_observing( + &host.connection.session, + SpawnParams { + process_id: 0, + argv: vec![candidate.clone(), "--version".into()], + cwd: None, + environment: EnvironmentSource::Login, + env: BTreeMap::new(), + pipe_stdin: false, + }, + None, + &mut |event| { + if event.event == methods::EVENT_PROCESS_OUTPUT { + let output: methods::ProcessOutputEvent = + event.decode().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git version") + .with_details(error.to_string()) + })?; + let bytes = methods::decode_bytes(&output.data).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote Git version bytes", + ) + .with_details(error.to_string()) + })?; + if version.len() + bytes.len() > 4096 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote Git version output exceeded its limit", + )); + } + if output.stream == OutputStream::Stdout { + version.extend(bytes); + } + } + Ok(()) + }, + )?; + if result["exitCode"] != 0 { + return Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not inspect remote Git version", + )); + } + let version = String::from_utf8_lossy(&version).trim().to_owned(); + execution_policy::validate_version(&version)?; + *host + .connection + .git_tool + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some((candidate.clone(), version)); + return Ok(candidate); + } + } + Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Git is not available on the remote login PATH", + )) +} diff --git a/rust/lithe-core/src/git/rewrite.rs b/rust/lithe-core/src/git/rewrite.rs index 79200e9bcc..c740510779 100644 --- a/rust/lithe-core/src/git/rewrite.rs +++ b/rust/lithe-core/src/git/rewrite.rs @@ -1,9 +1,9 @@ //! Reviewed local history mutations with immutable snapshots and durable recovery refs. use super::*; +use crate::remote::workspace_io::{self as fs, File}; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap}; -use std::fs::{self, File}; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -134,7 +134,9 @@ impl RewriteLease { .map_err(|_| invalid("Unsupported Git metadata path encoding"))? .trim(), ); - let common = common.canonicalize().map_err(snapshot_io_error)?; + let common = crate::remote::workspace::identity_key( + &fs::canonicalize(&common).map_err(snapshot_io_error)?, + ); let mut active = ACTIVE_REWRITES .get_or_init(|| Mutex::new(HashMap::new())) .lock() diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 60b26ce11b..5279d65d18 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -738,6 +738,8 @@ struct RuntimeSession { provider_id: String, /// Plugin that owns the process, if any; see `StartServerRequest::plugin_id`. plugin_id: Option, + /// Fixed workspace host, inherited by protocol workers; never a global selection. + workspace_host: Option, /// Maven import settings JDT LS currently holds. Replaced as a whole when /// the workspace's Maven configuration changes; never held across another /// lock. @@ -1467,6 +1469,13 @@ impl LspEngine { fn start_server(&self, request: StartServerRequest) -> Result { validate_start_request(&request)?; + if crate::remote::workspace::is_remote() && request.provider_id.eq_ignore_ascii_case("java") + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "Remote Java language servers require a host-compatible plugin launch plan", + )); + } let startup_started_at = Instant::now(); let session_id = format!( "lsp-session-{}", @@ -1615,7 +1624,8 @@ impl LspEngine { ClientInitializeRequest { state: LspClientState::default(), root_uri: request.root_uri.clone(), - process_id: Some(std::process::id() as i64), + process_id: (!crate::remote::workspace::is_remote()) + .then_some(std::process::id() as i64), initialization_options: adapt_initialization_options( &request.provider_id, request.initialization_options, @@ -1648,6 +1658,7 @@ impl LspEngine { id: session_id.clone(), provider_id: request.provider_id, plugin_id: request.plugin_id, + workspace_host: crate::remote::workspace::current(), jdt_maven_configuration: Mutex::new(jdt_maven_configuration), maven_inputs, maven_update_order: Mutex::new(()), @@ -2871,6 +2882,7 @@ impl RuntimeSession { ) { let output_session = self.clone(); thread::spawn(move || { + let _host = crate::remote::workspace::enter(output_session.workspace_host.clone()); let mut frame_buffer = Vec::new(); let mut chunk = vec![0_u8; 8 * 1024]; while output_session.active.load(Ordering::Acquire) { @@ -2970,21 +2982,7 @@ impl RuntimeSession { CoreError::new(ErrorCode::ParseFailed, "Invalid LSP server JSON message.") .with_details(error.to_string()) })?; - let method = value.get("method").and_then(Value::as_str); - let readiness = readiness_signal(&self.provider_id, method, value.get("params")); - let java_import_progress = import_progress(&self.provider_id, method, value.get("params")); let outbound_order = self.lock_outbound_order()?; - - if value.get("method").and_then(Value::as_str) == Some("workspace/configuration") { - if let Some(response) = self.provider_configuration_response(&value)? { - return self.send_messages_or_fail( - &outbound_order, - vec![response], - "serverRequest", - ); - } - } - let response_id = if value.get("method").is_none() { lsp_value_id(value.get("id")) } else { @@ -3013,6 +3011,36 @@ impl RuntimeSession { ); return Ok(()); } + let message = match &self.workspace_host { + Some(host) => super::remote_process::map_message( + host, + &message, + false, + pending_before + .as_ref() + .map(|pending| pending.method.as_str()), + )?, + None => message, + }; + let value: Value = serde_json::from_str(&message).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid mapped LSP server JSON message.", + ) + .with_details(error.to_string()) + })?; + let method = value.get("method").and_then(Value::as_str); + let readiness = readiness_signal(&self.provider_id, method, value.get("params")); + let java_import_progress = import_progress(&self.provider_id, method, value.get("params")); + if method == Some("workspace/configuration") { + if let Some(response) = self.provider_configuration_response(&value)? { + return self.send_messages_or_fail( + &outbound_order, + vec![response], + "serverRequest", + ); + } + } let reduced = { let mut state = self.lock_state()?; @@ -4831,6 +4859,10 @@ impl RuntimeSession { messages: Vec, ) -> Result<(), CoreError> { for message in messages { + let message = match &self.workspace_host { + Some(host) => super::remote_process::map_message(host, &message, true, None)?, + None => message, + }; let frame = frame_message(FrameMessageRequest { message })?.frame; self.process.write_input(frame.as_bytes())?; } @@ -4968,7 +5000,7 @@ fn java_executable_from_environment(environment: &BTreeMap) -> O /// `None` for other schemes and for files that do not exist. fn resolved_file_of_uri(uri: &str) -> Option { let path = url::Url::parse(uri).ok()?.to_file_path().ok()?; - std::fs::canonicalize(path).ok() + crate::remote::workspace_io::canonicalize(path).ok() } /// Which open documents the host reached through a symbolic link. @@ -5058,7 +5090,7 @@ fn java_entrypoint_scope_uri(root_uri: &str) -> String { url::Url::parse(root_uri) .ok() .and_then(|url| url.to_file_path().ok()) - .and_then(|path| std::fs::canonicalize(path).ok()) + .and_then(|path| crate::remote::workspace_io::canonicalize(path).ok()) .and_then(|canonical| url::Url::from_directory_path(canonical).ok()) .map(|url| url.to_string()) .unwrap_or_else(|| root_uri.to_string()) @@ -5070,7 +5102,7 @@ fn java_entrypoint_scope_uri(root_uri: &str) -> String { /// rather than in the pure normalizer that receives the result. fn canonical_workspace_root(root_uri: &str) -> Option { let path = url::Url::parse(root_uri).ok()?.to_file_path().ok()?; - let canonical = std::fs::canonicalize(path).ok()?; + let canonical = crate::remote::workspace_io::canonicalize(path).ok()?; let canonical = canonical.to_string_lossy().replace('\\', "/"); // Windows canonical paths carry a verbatim `\\?\` prefix that JDT never reports. Some( diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 7214d5a168..a31b8219e5 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -4,7 +4,8 @@ mod build_provider; mod client; mod engine; pub(crate) mod plugin_function; -mod process; +pub(crate) mod process; +pub(crate) mod remote_process; #[cfg(test)] mod scripted; mod transport; diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs index b028c52fa6..2ac05939a6 100644 --- a/rust/lithe-core/src/lsp/interface/process.rs +++ b/rust/lithe-core/src/lsp/interface/process.rs @@ -42,8 +42,9 @@ pub trait LspProcessHandle: Send + Sync { None } - /// Writes one already-framed message and flushes it. Fails once the input - /// stream is gone, which the engine turns into a transport failure. + /// Accepts one complete frame in order. Native handles flush it directly; + /// remote handles enqueue bounded delivery without blocking the UI caller. + /// A delivery failure closes the streams and becomes a transport failure. fn write_input(&self, frame: &[u8]) -> Result<(), CoreError>; /// Drops the input stream so the server observes EOF on stdin. @@ -75,6 +76,9 @@ pub struct SystemProcessLauncher; impl LspProcessLauncher for SystemProcessLauncher { fn launch(&self, spec: LspProcessSpec) -> Result { + if let Some(host) = crate::remote::workspace::current() { + return super::remote_process::launch(host, spec); + } let mut command = language_server_command(&spec); command .current_dir(&spec.working_directory) diff --git a/rust/lithe-core/src/lsp/interface/remote_process.rs b/rust/lithe-core/src/lsp/interface/remote_process.rs new file mode 100644 index 0000000000..9d0a85c27f --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/remote_process.rs @@ -0,0 +1,666 @@ +//! Bounded remote stdio for the existing plugin and language-server engine. +//! The process handle keeps its original connection; changing workspaces never +//! redirects a running backend or its cleanup to another host. + +use super::process::{LspProcessHandle, LspProcessSpec, LspProcessStreams}; +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use crate::remote::sessions::{request_error, Connection}; +use crate::remote::workspace::Host; +use lithe_remote_client::session::{ClientError, Event}; +use lithe_remote_protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use serde_json::json; +use serde_json::Value; +use std::collections::VecDeque; +use std::io::{self, Read}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::RecvTimeoutError; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +const START_TIMEOUT: Duration = Duration::from_secs(30); +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); +const POLL: Duration = Duration::from_millis(20); +const STREAM_BYTES: usize = 8 * 1024 * 1024; +const INPUT_BYTES: usize = 8 * 1024 * 1024; + +/// Rewrites protocol-owned URI fields, including WorkspaceEdit map keys. Text, +/// snippets, hover contents and arbitrary plugin function values stay verbatim. +pub(crate) fn map_message( + host: &Host, + message: &str, + outbound: bool, + response_method: Option<&str>, +) -> Result { + let mut value: Value = serde_json::from_str(message).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote backend JSON") + .with_details(error.to_string()) + })?; + let method = value + .get("method") + .and_then(Value::as_str) + .or(response_method) + .unwrap_or_default() + .to_owned(); + // A JSON-RPC result has no method of its own. Its request determines + // whether it is an LSP structure or opaque plugin-owned data. + if method.starts_with("lithe/") { + if outbound && method.starts_with("lithe/vcs/") { + for pointer in ["/params/root/path", "/params/tool/executable"] { + if let Some(value) = value.pointer_mut(pointer) { + if let Some(path) = value.as_str() { + *value = Value::String( + host.remote_path(std::path::Path::new(path)) + .to_string_lossy() + .into_owned(), + ); + } + } + } + } + return Ok(value.to_string()); + } + if method.is_empty() + || matches!( + method.as_str(), + "workspace/configuration" | "workspace/executeCommand" | "$/progress" + ) + || !(method == "initialize" + || method.starts_with("textDocument/") + || method.starts_with("workspace/") + || method.starts_with("window/") + || method.starts_with("callHierarchy/") + || method.starts_with("typeHierarchy/") + || method.starts_with("completionItem/") + || method.starts_with("codeAction/") + || method.starts_with("codeLens/") + || method.starts_with("documentLink/") + || method.starts_with("inlayHint/")) + { + return Ok(value.to_string()); + } + fn visit(host: &Host, value: &mut Value, outbound: bool) { + match value { + Value::Array(values) => { + for value in values { + visit(host, value, outbound); + } + } + Value::Object(object) => { + for (key, value) in object.iter_mut() { + if matches!( + key.as_str(), + "uri" | "rootUri" | "targetUri" | "oldUri" | "newUri" | "documentUri" + ) { + if let Some(mapped) = + value.as_str().and_then(|uri| host.map_uri(uri, outbound)) + { + *value = Value::String(mapped); + } + } else if key == "changes" { + if let Some(changes) = value.as_object_mut() { + let mapped = std::mem::take(changes) + .into_iter() + .map(|(uri, edits)| { + (host.map_uri(&uri, outbound).unwrap_or(uri), edits) + }) + .collect(); + *changes = mapped; + } + } + // These fields contain user/plugin data rather than LSP structures. + if !matches!( + key.as_str(), + "text" + | "newText" + | "contents" + | "documentation" + | "arguments" + | "data" + | "initializationOptions" + | "experimental" + | "settings" + | "value" + ) { + visit(host, value, outbound); + } + } + } + _ => {} + } + } + // Error data belongs to the server; it is never an LSP location result. + if let Some(payload) = value.get_mut("params") { + visit(host, payload, outbound); + } + if let Some(payload) = value.get_mut("result") { + visit(host, payload, outbound); + } + Ok(value.to_string()) +} + +/// One bounded byte stream. Readers wake on exit, disconnect and termination. +#[derive(Default)] +struct Pipe { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct PipeState { + chunks: VecDeque>, + bytes: usize, + offset: usize, + ended: bool, + failure: Option, +} + +impl Pipe { + fn push(&self, bytes: Vec) -> Result<(), CoreError> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.ended { + return Ok(()); + } + if state.bytes.saturating_add(bytes.len()) > STREAM_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend output exceeded its budget", + )); + } + if !bytes.is_empty() { + state.bytes += bytes.len(); + state.chunks.push_back(bytes); + } + self.changed.notify_all(); + Ok(()) + } + + fn end(&self, failure: Option) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.ended = true; + if state.failure.is_none() { + state.failure = failure; + } + self.changed.notify_all(); + } +} + +struct PipeReader(Arc); +impl Read for PipeReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + let mut state = self + .0 + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + loop { + if let Some(failure) = state.failure.take() { + return Err(io::Error::other(failure)); + } + if let Some(chunk) = state.chunks.front() { + let count = buffer.len().min(chunk.len() - state.offset); + buffer[..count].copy_from_slice(&chunk[state.offset..state.offset + count]); + state.offset += count; + state.bytes -= count; + if state.offset == state.chunks.front().unwrap().len() { + state.chunks.pop_front(); + state.offset = 0; + } + return Ok(count); + } + if state.ended { + return Ok(0); + } + state = self + .0 + .changed + .wait(state) + .unwrap_or_else(|error| error.into_inner()); + } + } +} + +/// One writer owns the host stdin. Callers only enqueue complete frames; +/// a slow network or host reader cannot block document synchronization. +#[derive(Default)] +struct Input { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct InputState { + frames: VecDeque>, + /// Includes the frame currently being delivered, until its acknowledgement. + bytes: usize, + closed: bool, + stopped: bool, +} + +impl Input { + fn enqueue(&self, frame: &[u8]) -> Result<(), CoreError> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.stopped || state.closed { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend stdin is closed", + )); + } + if state.bytes.saturating_add(frame.len()) > INPUT_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend input exceeded its budget", + )); + } + state.bytes += frame.len(); + state.frames.push_back(frame.to_vec()); + self.changed.notify_one(); + Ok(()) + } + + fn close(&self) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .closed = true; + self.changed.notify_one(); + } + + fn stop(&self) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.stopped = true; + state.frames.clear(); + state.bytes = 0; + self.changed.notify_one(); + } +} + +struct RemoteProcess { + connection: Arc, + id: u64, + stdout: Arc, + stderr: Arc, + exit: Mutex>>, + stopped: AtomicBool, + input: Arc, +} + +impl RemoteProcess { + fn finish(&self, exit: Option, failure: Option) { + let mut state = self.exit.lock().unwrap_or_else(|error| error.into_inner()); + if state.is_none() { + *state = Some(exit); + } + self.stdout.end(failure.clone()); + self.stderr.end(failure); + } + + fn cleanup(&self) { + if self.stopped.swap(true, Ordering::AcqRel) { + return; + } + self.input.stop(); + self.connection.session.unsubscribe("process", self.id); + let result = self.connection.session.request_value( + methods::PROCESS_KILL, + json!({"processId": self.id, "force": true}), + CLEANUP_TIMEOUT, + ); + if let Err(error) = result { + if !matches!(&error, ClientError::Remote(remote) if remote.code == lithe_remote_protocol::ErrorCode::UnknownResource) + && !self.connection.session.is_closed() + { + eprintln!("remote backend cleanup failed: {error}"); + } + } + self.finish(None, None); + } + + fn event(&self, event: Event) -> Result { + if event.resource() != Some(("process", self.id)) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Remote backend identity changed", + )); + } + match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => { + let output: methods::ProcessOutputEvent = + event.decode().map_err(|error| request_error("", error))?; + let bytes = methods::decode_bytes(&output.data).map_err(|_| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote backend bytes") + })?; + let pipe = if output.stream == OutputStream::Stdout { + &self.stdout + } else { + &self.stderr + }; + pipe.push(bytes)?; + Ok(false) + } + methods::EVENT_PROCESS_EXIT => { + let exit: methods::ProcessExitEvent = + event.decode().map_err(|error| request_error("", error))?; + self.finish(exit.exit_code, None); + Ok(true) + } + _ => Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote backend event", + )), + } + } +} + +impl Drop for RemoteProcess { + fn drop(&mut self) { + self.cleanup(); + } +} + +impl RemoteProcess { + fn deliver_input(&self, frame: &[u8]) -> Result<(), CoreError> { + let deadline = Instant::now() + WRITE_TIMEOUT; + for chunk in frame.chunks(32 * 1024) { + loop { + if self.stopped.load(Ordering::Acquire) { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend stdin is closed", + )); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(CoreError::new( + ErrorCode::TimedOut, + "Remote backend stdin timed out", + )); + } + let pending = self + .connection + .session + .begin_request_value( + methods::PROCESS_WRITE_STDIN, + json!({"processId": self.id, "data": methods::encode_bytes(chunk)}), + remaining, + ) + .map_err(|error| request_error("", error))?; + let outcome = loop { + if self.stopped.load(Ordering::Acquire) { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend stopped", + )); + } + match pending.poll(POLL) { + Ok(None) => {} + result => break result, + } + }; + match outcome { + Ok(_) => break, + Err(ClientError::Remote(error)) + if error.code == lithe_remote_protocol::ErrorCode::Busy => + { + // A full host stdin queue retries only within this frame's deadline. + std::thread::park_timeout( + POLL.min(deadline.saturating_duration_since(Instant::now())), + ); + } + Err(error) => return Err(request_error("", error)), + } + } + } + Ok(()) + } +} + +impl LspProcessHandle for RemoteProcess { + // A remote pid must never enter the local process registry. + fn write_input(&self, frame: &[u8]) -> Result<(), CoreError> { + self.input.enqueue(frame) + } + fn close_input(&self) { + self.input.close(); + } + fn exit_status(&self) -> Option> { + *self.exit.lock().unwrap_or_else(|error| error.into_inner()) + } + fn terminate(&self) { + self.cleanup(); + } +} + +pub(super) fn launch(host: Host, spec: LspProcessSpec) -> Result { + let connection = host.connection.clone(); + let id = connection.session.allocate_id(); + let events = connection.session.subscribe("process", id); + let process = Arc::new(RemoteProcess { + connection, + id, + stdout: Arc::default(), + stderr: Arc::default(), + exit: Mutex::new(None), + stopped: AtomicBool::new(false), + input: Arc::default(), + }); + let parameters = SpawnParams { + process_id: id, + argv: std::iter::once( + host.remote_path(&spec.executable) + .to_string_lossy() + .into_owned(), + ) + .chain(spec.arguments) + .collect(), + cwd: Some( + host.remote_path(&spec.working_directory) + .to_string_lossy() + .into_owned(), + ), + environment: EnvironmentSource::Login, + env: spec.environment, + pipe_stdin: true, + }; + let pending = process + .connection + .session + .begin_request_value( + methods::PROCESS_SPAWN, + serde_json::to_value(parameters).expect("backend spawn parameters encode"), + START_TIMEOUT, + ) + .map_err(|error| request_error("", error))?; + loop { + cancellation::check()?; + while let Ok(event) = events.try_recv() { + process.event(event)?; + } + if pending + .poll(POLL) + .map_err(|error| request_error("", error))? + .is_some() + { + break; + } + } + let input = process.input.clone(); + let writer = Arc::downgrade(&process); + std::thread::Builder::new() + .name("remote-plugin-stdin".into()) + .spawn(move || loop { + let frame = { + let mut state = input + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + loop { + if state.stopped { + return; + } + if let Some(frame) = state.frames.pop_front() { + break Some(frame); + } + if state.closed { + break None; + } + state = input + .changed + .wait(state) + .unwrap_or_else(|error| error.into_inner()); + } + }; + let Some(process) = writer.upgrade() else { + return; + }; + let outcome = match &frame { + Some(frame) => process.deliver_input(frame), + None => process + .connection + .session + .request_value( + methods::PROCESS_CLOSE_STDIN, + json!({"processId": process.id}), + CLEANUP_TIMEOUT, + ) + .map(|_| ()) + .map_err(|error| request_error("", error)), + }; + if let Err(error) = outcome { + process.finish(None, Some(error.message)); + process.cleanup(); + return; + } + if let Some(frame) = frame { + let mut state = input + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.bytes = state.bytes.saturating_sub(frame.len()); + } else { + return; + } + }) + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not own remote backend input", + ) + .with_details(error.to_string()) + })?; + // The worker owns no strong handle while idle. Dropping the engine handle + // closes its subscription, wakes both readers and ends the remote tree. + let weak = Arc::downgrade(&process); + std::thread::Builder::new() + .name("remote-plugin-stdio".into()) + .spawn(move || loop { + let Some(process) = weak.upgrade() else { + break; + }; + if process.stopped.load(Ordering::Acquire) { + break; + } + let result = match events.recv_timeout(POLL) { + Ok(event) => process.event(event), + Err(RecvTimeoutError::Timeout) if !process.connection.session.is_closed() => { + continue + } + Err(_) => Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend disconnected", + )), + }; + match result { + Ok(false) => {} + Ok(true) => { + process.cleanup(); + break; + } + Err(error) => { + process.finish(None, Some(error.message)); + process.cleanup(); + break; + } + } + }) + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not own remote backend output", + ) + .with_details(error.to_string()) + })?; + Ok(LspProcessStreams { + output: Box::new(PipeReader(process.stdout.clone())), + errors: Box::new(PipeReader(process.stderr.clone())), + handle: process, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_preserves_frames_and_counts_in_flight_bytes_until_acknowledged() { + let input = Input::default(); + input.enqueue(b"first").unwrap(); + input.enqueue(b"second").unwrap(); + input.close(); + assert!(input.enqueue(b"late").is_err()); + let mut state = input.state.lock().unwrap(); + assert_eq!(state.frames.pop_front().unwrap(), b"first"); + assert_eq!(state.frames.pop_front().unwrap(), b"second"); + assert_eq!( + state.bytes, 11, + "dequeued data remains budgeted during delivery" + ); + assert!(state.closed); + drop(state); + input.stop(); + assert_eq!(input.state.lock().unwrap().bytes, 0); + assert!(input.enqueue(b"stopped").is_err()); + } + + #[test] + fn input_overflow_rejects_a_whole_frame_without_discarding_prior_data() { + let input = Input::default(); + input.enqueue(&vec![b'x'; INPUT_BYTES]).unwrap(); + assert!(input.enqueue(b"overflow").is_err()); + let state = input.state.lock().unwrap(); + assert_eq!(state.frames.len(), 1); + assert_eq!(state.bytes, INPUT_BYTES); + } + + #[test] + fn output_partial_reads_preserve_order_and_terminal_failures_reach_readers() { + let pipe = Arc::new(Pipe::default()); + pipe.push(b"ab".to_vec()).unwrap(); + pipe.push(b"cd".to_vec()).unwrap(); + pipe.end(None); + let mut reader = PipeReader(pipe.clone()); + let mut byte = [0]; + reader.read_exact(&mut byte).unwrap(); + assert_eq!(byte, *b"a"); + let mut rest = Vec::new(); + reader.read_to_end(&mut rest).unwrap(); + assert_eq!(rest, b"bcd"); + assert_eq!(pipe.state.lock().unwrap().bytes, 0); + pipe.end(Some("connection closed".into())); + assert_eq!( + reader.read(&mut byte).unwrap_err().to_string(), + "connection closed" + ); + } + + #[test] + fn output_budget_failure_does_not_accept_excess_bytes() { + let pipe = Pipe::default(); + pipe.push(vec![0; STREAM_BYTES]).unwrap(); + assert!(pipe.push(vec![0]).is_err()); + assert_eq!(pipe.state.lock().unwrap().bytes, STREAM_BYTES); + } +} diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index c9eaed5ac5..8e3556f734 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -8,10 +8,10 @@ use crate::protocol::{ ReplacementPreviewResponse, SearchMatch, SearchResponse, WorkspaceNode, WorkspaceSnapshotResponse, }; +use crate::remote::workspace_io as fs; use regex::{Regex, RegexBuilder}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::fs; use std::path::{Path, PathBuf}; /// Default workspace exclusions, also used by Git discovery for generated directories. @@ -1086,7 +1086,7 @@ pub(crate) fn is_nested_linked_worktree(directory: &Path) -> bool { return false; }; let (Ok(main_worktree), Ok(directory)) = - (main_worktree.canonicalize(), directory.canonicalize()) + (fs::canonicalize(main_worktree), fs::canonicalize(directory)) else { return false; }; @@ -1120,7 +1120,7 @@ fn existing_root(value: &str) -> Result { "Workspace root must be a directory", )); } - path.canonicalize().map_err(CoreError::from) + fs::canonicalize(path).map_err(CoreError::from) } fn safe_relative_path(root: &Path, value: &str) -> Result { @@ -1132,7 +1132,7 @@ fn safe_relative_path(root: &Path, value: &str) -> Result { )); } let path = root.join(relative); - let canonical = path.canonicalize().map_err(CoreError::from)?; + let canonical = fs::canonicalize(path).map_err(CoreError::from)?; if !canonical.starts_with(root) { return Err(CoreError::new( ErrorCode::PermissionDenied, @@ -1153,12 +1153,12 @@ fn writable_relative_path(root: &Path, value: &str) -> Result Result) -> Option) -> Option) -> Option Result, CoreError> { - let canonical_root = root.canonicalize().map_err(CoreError::from)?; + let canonical_root = fs::canonicalize(root).map_err(CoreError::from)?; let mut candidate_directories = BTreeSet::from([PathBuf::new()]); for path in paths .iter() @@ -1186,10 +1185,10 @@ pub(crate) fn maven_root( .into_iter() .filter_map(|directory| { let candidate = root.join(&directory); - if !candidate.join("pom.xml").is_file() { + if !fs::is_file(candidate.join("pom.xml")).ok()? { return None; } - let canonical_candidate = candidate.canonicalize().ok()?; + let canonical_candidate = fs::canonicalize(&candidate).ok()?; if !canonical_candidate.starts_with(&canonical_root) { return None; } @@ -1950,15 +1949,15 @@ fn should_skip_test_source_directory(path: &Path) -> bool { } fn workspace_relative_path(root: &Path, path: &Path) -> Option { - let canonical_root = root.canonicalize().ok()?; - let canonical_path = path.canonicalize().ok()?; + let canonical_root = fs::canonicalize(root).ok()?; + let canonical_path = fs::canonicalize(path).ok()?; let relative = canonical_path.strip_prefix(canonical_root).ok()?; Some(relative.to_string_lossy().replace('\\', "/")) } /// Reads a scanned POM only when links still resolve inside its reactor. fn contained_descriptor(root: &Path, path: &Path) -> Result, CoreError> { - let (Ok(root), Ok(path)) = (root.canonicalize(), path.canonicalize()) else { + let (Ok(root), Ok(path)) = (fs::canonicalize(root), fs::canonicalize(path)) else { return Ok(None); }; if !path.starts_with(root) { @@ -2476,7 +2475,7 @@ fn existing_root(value: &str) -> Result { "Workspace root must be a directory", )); } - path.canonicalize().map_err(CoreError::from) + fs::canonicalize(path).map_err(CoreError::from) } fn normalize_relative_path(value: &str) -> Option { @@ -2511,13 +2510,12 @@ fn non_empty(value: String) -> Option { fn has_wrapper(root: &Path) -> bool { let unix = root.join("mvnw"); let windows = root.join("mvnw.cmd"); - windows.is_file() + fs::is_file(windows).unwrap_or(false) || fs::metadata(unix) .map(|metadata| { #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + metadata.is_file() && metadata.mode() & 0o111 != 0 } #[cfg(not(unix))] { diff --git a/rust/lithe-core/src/project/maven_dependency_tree.rs b/rust/lithe-core/src/project/maven_dependency_tree.rs index f20674971d..cb204b682c 100644 --- a/rust/lithe-core/src/project/maven_dependency_tree.rs +++ b/rust/lithe-core/src/project/maven_dependency_tree.rs @@ -19,8 +19,8 @@ use crate::protocol::{ CoreError, ErrorCode, MavenDependenciesResponse, MavenDependencyResolutionResponse, MavenDependencyResponse, }; +use crate::remote::workspace_io::File; use serde::Deserialize; -use std::fs::File; use std::io::{BufRead, BufReader, ErrorKind, Read}; use std::path::Path; diff --git a/rust/lithe-core/src/project/maven_test_reports.rs b/rust/lithe-core/src/project/maven_test_reports.rs index ffc1a59aca..5835844503 100644 --- a/rust/lithe-core/src/project/maven_test_reports.rs +++ b/rust/lithe-core/src/project/maven_test_reports.rs @@ -13,11 +13,11 @@ //! Note: the ownership decision is recorded in .agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md use crate::protocol::{CoreError, ErrorCode, MavenTestCaseResponse}; +use crate::remote::workspace_io as fs; use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; use serde::Deserialize; use std::collections::BTreeMap; -use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, UNIX_EPOCH}; @@ -405,17 +405,15 @@ pub(crate) fn module_root( _ => Ok(workspace_root.to_path_buf()), }; }; - let candidate = workspace_root - .join(module.replace('\\', "/")) - .canonicalize() - .map_err(|_| { + let candidate = + fs::canonicalize(workspace_root.join(module.replace('\\', "/"))).map_err(|_| { CoreError::new( ErrorCode::InvalidRequest, "Maven test module does not exist", ) .with_details(module.to_string()) })?; - if !candidate.starts_with(workspace_root) || !candidate.is_dir() { + if !candidate.starts_with(workspace_root) || !fs::is_dir(&candidate).map_err(CoreError::from)? { return Err(CoreError::new( ErrorCode::InvalidRequest, "Maven test module must be inside the workspace", @@ -428,10 +426,8 @@ pub(crate) fn module_root( /// The closest directory at or above `source_path`'s parent that holds a /// `pom.xml`, never leaving the workspace; the workspace root otherwise. fn nearest_module(workspace_root: &Path, source_path: &str) -> Result { - let source = workspace_root - .join(source_path.replace('\\', "/")) - .canonicalize() - .map_err(|_| { + let source = + fs::canonicalize(workspace_root.join(source_path.replace('\\', "/"))).map_err(|_| { CoreError::new( ErrorCode::InvalidRequest, "Maven test source does not exist", @@ -448,7 +444,7 @@ fn nearest_module(workspace_root: &Path, source_path: &str) -> Result, hidden_file_patterns: Vec, names_only: bool, @@ -77,6 +78,7 @@ fn cache() -> &'static std::sync::Mutex> { fn key(root: &Path, rules: &VisibilityRules) -> IndexKey { IndexKey { root: root.to_path_buf(), + host: workspace::identity_key(root), hidden_directories: rules.hidden_directories.clone(), hidden_file_patterns: rules.hidden_file_patterns.clone(), names_only: false, @@ -98,8 +100,9 @@ pub(crate) fn get_or_build(root: &Path, rules: &VisibilityRules) -> Result Result>() }; @@ -178,7 +182,7 @@ pub(crate) fn update_paths( indexes .iter() // Search-only exclusions create rule variants; keep all warm variants current. - .filter(|(cache_key, _)| cache_key.root == root) + .filter(|(cache_key, _)| cache_key.host == workspace::identity_key(root)) .map(|(key, index)| (key.clone(), Arc::clone(index))) .collect::>() }; @@ -391,18 +395,18 @@ fn normalized_update_path(root: &Path, value: &str) -> Option { } pub(crate) fn canonicalize_with_missing_components(path: &Path) -> Option { - if let Ok(canonical) = path.canonicalize() { + if let Ok(canonical) = fs::canonicalize(path) { return Some(canonical); } // Deleted paths cannot be canonicalized directly. Canonicalize their // nearest surviving ancestor, then append the missing path components. let mut ancestor = path; let mut suffix = Vec::::new(); - while !ancestor.exists() { + while !fs::try_exists(ancestor).ok()? { suffix.push(ancestor.file_name()?.to_os_string()); ancestor = ancestor.parent()?; } - let mut normalized = ancestor.canonicalize().ok()?; + let mut normalized = fs::canonicalize(ancestor).ok()?; for component in suffix.iter().rev() { normalized.push(component); } @@ -507,6 +511,7 @@ pub(crate) fn make_symbol_match(indexed: &IndexedSymbol, path: String) -> Search #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn source_caps_build_only_required_cold_indexes_and_skip_generated_text_enumeration() { diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 7d87d668b3..beb765df87 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -19,6 +19,9 @@ pub struct CoreRequest { /// Host-owned Git preferences, scoped to this request only. #[serde(default)] pub git_execution: Option, + /// Remote host selected by the native workspace registry; absent for local workspaces. + #[serde(default)] + pub(crate) remote_workspace: Option, /// Stable compatibility name resolved by [`CoreCommand::parse`]. pub command: String, /// Command-specific JSON object; omitted payloads deserialize as JSON null. @@ -396,6 +399,20 @@ pub enum CoreCommand { RemoteReadFile, /// Saves a file over an open remote connection (`remote.writeFile`). RemoteWriteFile, + /// Performs a non-streaming operation on an existing workspace host. + RemoteHostRequest, + /// Owns a remote process and relays output until it exits or is cancelled. + RemoteRunProcess, + /// Captures a short remote process with explicit time and output budgets. + RemoteCaptureProcess, + /// Owns a remote PTY and relays output until it exits or is cancelled. + RemoteRunTerminal, + /// Owns a host watch and relays changes until cancellation or root removal. + RemoteRunWatch, + /// Streams a native source into integrity-checked host file staging. + RemoteUploadFile, + /// Returns cached handshake capabilities of one connection. + RemoteHostInfo, /// Inspects repository initialization and scoped commit identity. GitRepositorySetup, /// Initializes an existing folder outside any repository. @@ -661,6 +678,13 @@ impl CoreCommand { "remote.closeAll" => Some(Self::RemoteCloseAll), "remote.readFile" => Some(Self::RemoteReadFile), "remote.writeFile" => Some(Self::RemoteWriteFile), + "remote.hostRequest" => Some(Self::RemoteHostRequest), + "remote.runProcess" => Some(Self::RemoteRunProcess), + "remote.captureProcess" => Some(Self::RemoteCaptureProcess), + "remote.runTerminal" => Some(Self::RemoteRunTerminal), + "remote.runWatch" => Some(Self::RemoteRunWatch), + "remote.uploadFile" => Some(Self::RemoteUploadFile), + "remote.hostInfo" => Some(Self::RemoteHostInfo), "git.historyRewritePreview" => Some(Self::GitHistoryRewritePreview), "git.rebasePreview" => Some(Self::GitRebasePreview), "git.repositorySetup" => Some(Self::GitRepositorySetup), diff --git a/rust/lithe-core/src/remote/mod.rs b/rust/lithe-core/src/remote/mod.rs index 4f48bc6f69..c2c4e885ab 100644 --- a/rust/lithe-core/src/remote/mod.rs +++ b/rust/lithe-core/src/remote/mod.rs @@ -18,6 +18,10 @@ use std::process::{Command, ExitStatus}; use std::time::{Duration, Instant}; pub(crate) mod sessions; +pub(crate) mod upload; +pub(crate) mod workspace; +pub(crate) mod workspace_host; +pub(crate) mod workspace_io; /// Seconds OpenSSH waits for the TCP connection when the profile sets none. const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; diff --git a/rust/lithe-core/src/remote/sessions.rs b/rust/lithe-core/src/remote/sessions.rs index 5c8e627592..5722f84191 100644 --- a/rust/lithe-core/src/remote/sessions.rs +++ b/rust/lithe-core/src/remote/sessions.rs @@ -87,12 +87,26 @@ pub(crate) struct ConnectResult { diagnostics: String, } -struct Connection { - session: RemoteSession, - child: Mutex, - tree: ProcessTree, +pub(crate) struct Connection { + pub(crate) session: RemoteSession, + pub(crate) git_tool: Mutex>, + transport: ConnectionTransport, } +/// Owns the native transport independently of the framed workspace session. +enum ConnectionTransport { + Ssh { + child: Mutex, + tree: ProcessTree, + }, + #[cfg(test)] + InProcess, +} + +#[cfg(all(test, unix))] +#[path = "sessions_integration_tests.rs"] +mod integration_tests; + impl Connection { fn close(&self) { let _ = self.session.request_value( @@ -100,13 +114,18 @@ impl Connection { serde_json::Value::Null, EXIT_GRACE, ); - let _ = self.tree.terminate(EXIT_GRACE); - let mut child = self - .child - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let _ = child.kill(); - let _ = child.wait(); + match &self.transport { + ConnectionTransport::Ssh { child, tree } => { + let _ = tree.terminate(EXIT_GRACE); + let mut child = child + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = child.kill(); + let _ = child.wait(); + } + #[cfg(test)] + ConnectionTransport::InProcess => {} + } } } @@ -138,7 +157,7 @@ fn registry() -> &'static Mutex>> { CONNECTIONS.get_or_init(Mutex::default) } -fn lookup(id: &str) -> Result, CoreError> { +pub(super) fn lookup(id: &str) -> Result, CoreError> { registry() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -434,8 +453,11 @@ pub(super) fn connect_with( id.clone(), Arc::new(Connection { session, - child: Mutex::new(child), - tree, + git_tool: Mutex::new(None), + transport: ConnectionTransport::Ssh { + child: Mutex::new(child), + tree, + }, }), ); Ok(ConnectResult { @@ -641,7 +663,8 @@ pub(super) fn close_registered() -> usize { count } -fn request_error(session_id: &str, error: ClientError) -> CoreError { +/// Translates host transport failures without losing their stable remote category. +pub(crate) fn request_error(session_id: &str, error: ClientError) -> CoreError { match error { ClientError::Remote(error) => { let code = match error.code { @@ -650,6 +673,7 @@ fn request_error(session_id: &str, error: ClientError) -> CoreError { _ => ErrorCode::ProcessFailed, }; CoreError::new(code, error.message) + .with_details(serde_json::json!({"remoteCode": error.code}).to_string()) } ClientError::Timeout => CoreError::new( ErrorCode::TimedOut, diff --git a/rust/lithe-core/src/remote/sessions_integration_tests.rs b/rust/lithe-core/src/remote/sessions_integration_tests.rs new file mode 100644 index 0000000000..e768f1a6da --- /dev/null +++ b/rust/lithe-core/src/remote/sessions_integration_tests.rs @@ -0,0 +1,816 @@ +//! Real framed-host integration coverage for remote workspace semantic engines. + +use super::*; +use serde_json::{json, Value}; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; + +const STEP: Duration = Duration::from_secs(10); + +/// Owns socket deadlines, registered identity, server completion and scratch data. +struct HostFixture { + id: String, + root: std::path::PathBuf, + socket: UnixStream, + done: mpsc::Receiver<()>, + worker: Option>, +} + +impl HostFixture { + fn new() -> Self { + let root = crate::tests::support::temporary_root("remote-git-integration"); + std::fs::create_dir_all(root.join("host")).unwrap(); + std::fs::create_dir_all(root.join("placeholder")).unwrap(); + let root = root.canonicalize().unwrap(); + let (client, server) = UnixStream::pair().unwrap(); + server.set_read_timeout(Some(STEP)).unwrap(); + server.set_write_timeout(Some(STEP)).unwrap(); + let socket = client.try_clone().unwrap(); + let (finished, done) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = lithe_remote_server::serve_stdio(server.try_clone().unwrap(), server); + let _ = finished.send(()); + result.unwrap(); + }); + let (session, events) = RemoteSession::from_streams( + client.try_clone().unwrap(), + client, + &ConnectOptions { + handshake_timeout: STEP, + ..ConnectOptions::default() + }, + ) + .unwrap(); + drop(events); + let id = nonce(); + registry().lock().unwrap().insert( + id.clone(), + Arc::new(Connection { + session, + git_tool: Mutex::new(None), + transport: ConnectionTransport::InProcess, + }), + ); + Self { + id, + root, + socket, + done, + worker: Some(worker), + } + } + + fn call(&self, command: &str, mut payload: Value) -> Value { + payload["root"] = json!(self.root.join("placeholder")); + self.request(command, payload) + } + + fn request(&self, command: &str, payload: Value) -> Value { + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "id": nonce(), "command": command, "timeoutMilliseconds": 30_000, + "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": self.root.join("host")}, + "payload": payload, + }).to_string())).unwrap(); + assert_eq!(response["ok"], true, "{command}: {response}"); + assert!( + response["data"]["operationError"].is_null(), + "{command}: {response}" + ); + response["data"].clone() + } + + fn git(&self, args: &[&str]) -> Value { + let data = self.call("git.command", json!({"arguments": args})); + assert_eq!(data["exitCode"], 0, "{args:?}: {data}"); + data + } +} + +#[test] +fn remote_build_detects_host_models_normalizes_diagnostics_and_invalidates_sync() { + let host = HostFixture::new(); + let root = host.root.join("host"); + let placeholder = host.root.join("placeholder"); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"host-only\"\nversion = \"1.0.0\"\n[features]\nfast = []\n", + ) + .unwrap(); + let manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json" + ))) + .unwrap(); + let detection = host.request( + "build.detect", + json!({"workspaceRoot": placeholder, + "plugins": [{"manifest": manifest}], "options": {}}), + ); + assert_eq!(detection["status"], "ok", "{detection}"); + let instance = &detection["detection"]["instances"][0]; + assert_eq!(instance["id"], "cargo:.", "{detection}"); + assert_eq!( + instance["model"]["projects"][0]["name"], "host-only", + "{detection}" + ); + let call = |command, request| { + host.request( + command, + json!({"manifest": manifest, + "buildSystemID": "cargo", "workspaceRoot": placeholder, "request": request}), + ) + }; + let diagnostics = call( + "build.decodeDiagnostics", + json!({"workspaceRoot": placeholder, "root": ".", + "output": format!("{}/src/main.rs:2:3: error: remote diagnostic", root.display())}), + ); + assert_eq!( + diagnostics["diagnostics"][0]["path"], "src/main.rs", + "{diagnostics}" + ); + let sync = || { + call( + "build.syncFingerprint", + json!({"workspaceRoot": placeholder, "root": ".", "projects": ["."], "options": {}}), + ) + }; + let before = sync(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"changed\"\nversion = \"1.0.0\"\n", + ) + .unwrap(); + let after = sync(); + assert_ne!(before, after, "{before}"); + // Reported symlinks must not expose host paths outside this workspace. + std::fs::write(host.root.join("outside.rs"), "private").unwrap(); + std::os::unix::fs::symlink(host.root.join("outside.rs"), root.join("src/link.rs")).unwrap(); + let escaped = call( + "build.decodeDiagnostics", + json!({"workspaceRoot": placeholder, "root": ".", + "output": format!("{}/src/link.rs:1:1: error: escaped", root.display())}), + ); + assert_eq!(escaped["diagnostics"], json!([]), "{escaped}"); + assert_eq!(std::fs::read_dir(placeholder).unwrap().count(), 0); +} + +#[test] +fn remote_maven_build_reads_reactor_and_current_host_test_reports() { + let host = HostFixture::new(); + let root = host.root.join("host"); + let placeholder = host.root.join("placeholder"); + std::fs::create_dir_all(root.join("child/target/surefire-reports")).unwrap(); + std::fs::write(root.join("pom.xml"), "4.0.0fixtureparent1pomchild").unwrap(); + std::fs::write(root.join("child/pom.xml"), "4.0.0fixtureparent1child").unwrap(); + std::fs::write(root.join("child/target/surefire-reports/TEST-Fixture.xml"), "").unwrap(); + let manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" + ))) + .unwrap(); + let detected = host.request( + "build.detect", + json!({"workspaceRoot": placeholder, + "plugins": [{"manifest": manifest}], "options": {}}), + ); + assert_eq!(detected["status"], "ok", "{detected}"); + let projects = detected["detection"]["instances"][0]["model"]["projects"] + .as_array() + .unwrap(); + assert!( + projects.iter().any(|project| project["path"] == "child"), + "{detected}" + ); + let call = |not_before| { + host.request("build.decodeTestResults", json!({"manifest": manifest, + "buildSystemID": "maven", "workspaceRoot": placeholder, "request": {"workspaceRoot": placeholder, + "root": ".", "projects": [".", "child"], "output": "", "notBeforeMillis": not_before}})) + }; + let current = call(0); + assert_eq!(current["status"], "ok", "{current}"); + assert!(current.to_string().contains("hostTest"), "{current}"); + let stale = call(u64::MAX / 2); + assert!(!stale.to_string().contains("hostTest"), "{stale}"); +} + +impl Drop for HostFixture { + fn drop(&mut self) { + let _ = crate::execute_json(&json!({ + "id": nonce(), "command": "workspace.searchIndex.invalidate", "timeoutMilliseconds": 1_000, + "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": self.root.join("host")}, + "payload": {"root": self.root.join("placeholder")} + }).to_string()); + if let Some(connection) = registry().lock().unwrap().remove(&self.id) { + connection.close(); + } + let _ = self.socket.shutdown(Shutdown::Both); + if self.done.recv_timeout(STEP + STEP).is_ok() { + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } else { + if !std::thread::panicking() { + panic!("remote test host did not finish within its cleanup deadline"); + } + } + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[test] +fn remote_search_reads_host_content_and_applies_incremental_updates_without_local_cache_leaks() { + let host = HostFixture::new(); + std::fs::create_dir(host.root.join("host/src")).unwrap(); + std::fs::write( + host.root.join("host/src/RemoteName.java"), + "class RemoteName { void remoteMethod() {} }\n", + ) + .unwrap(); + // Warm the same URL locally first: host identity must separate its empty index. + let local: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": nonce(), "command": "workspace.searchEverywhere", + "payload": {"root": host.root.join("placeholder"), "query": "RemoteName"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(local["ok"], true, "{local}"); + assert_eq!(local["data"]["matches"], json!([])); + let found = host.call("workspace.searchEverywhere", json!({"query": "RemoteName"})); + for kind in ["file", "type", "content"] { + assert!( + found["matches"] + .as_array() + .unwrap() + .iter() + .any(|item| item["kind"] == kind && item["path"] == "src/RemoteName.java"), + "{kind}: {found}" + ); + } + std::fs::write( + host.root.join("host/src/RemoteName.java"), + "class ChangedName {}\n", + ) + .unwrap(); + host.call( + "workspace.searchIndex.update", + json!({"paths": ["src/RemoteName.java"]}), + ); + let changed = host.call("workspace.search", json!({"query": "ChangedName"})); + assert!( + changed["matches"] + .as_array() + .unwrap() + .iter() + .any(|item| item["path"] == "src/RemoteName.java"), + "{changed}" + ); + let missing = host.call("workspace.search", json!({"query": "remoteMethod"})); + assert_eq!(missing["matches"], json!([])); + let read = host.call("file.read", json!({"path": "src/RemoteName.java"})); + assert_eq!(read["text"], "class ChangedName {}\n"); + host.call( + "file.write", + json!({"path": "written.txt", "text": "remote write\n"}), + ); + assert_eq!( + std::fs::read(host.root.join("host/written.txt")).unwrap(), + b"remote write\n" + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0 + ); + let local: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": nonce(), "command": "workspace.searchIndex.invalidate", + "payload": {"root": host.root.join("placeholder")} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(local["ok"], true, "{local}"); +} + +#[test] +fn real_remote_git_status_stage_commit_and_history_never_touch_the_placeholder() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "user.name", "Lithe Remote Test"]); + host.git(&["config", "user.email", "remote@example.invalid"]); + std::fs::write(host.root.join("host/remote.txt"), b"host-only\n").unwrap(); + let status = host.call("git.status", json!({})); + assert_eq!(status["repositoryRoot"], "."); + assert_eq!(status["changes"][0]["path"], "remote.txt"); + let staged = host.call( + "git.write", + json!({"operation": "stage", "paths": ["remote.txt"]}), + ); + assert_eq!(staged["exitCode"], 0, "{staged}"); + host.git(&["commit", "-qm", "first remote commit"]); + std::fs::write(host.root.join("host/remote.txt"), b"changed remotely\n").unwrap(); + host.git(&["add", "remote.txt"]); + host.git(&["commit", "-qm", "second remote commit"]); + let first = host.call("git.historyPage", json!({"limit": 1})); + assert_eq!(first["commits"][0]["subject"], "second remote commit"); + let cursor = first["nextCursor"].as_str().expect("a second page"); + let second = host.call("git.historyPage", json!({"limit": 1, "cursor": cursor})); + assert_eq!(second["commits"][0]["subject"], "first remote commit"); + if let Some(cursor) = second["nextCursor"].as_str() { + host.call("git.historyCursorClose", json!({"cursor": cursor})); + } + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0 + ); + assert!(host.root.join("host/.git/index").is_file()); +} + +#[test] +fn remote_git_snapshot_selected_commit_and_unsaved_line_history_preserve_the_real_index() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "user.name", "Lithe Remote Test"]); + host.git(&["config", "user.email", "remote@example.invalid"]); + std::fs::write(host.root.join("host/selected.txt"), b"one\nbase\n").unwrap(); + std::fs::write(host.root.join("host/unrelated.txt"), b"base\n").unwrap(); + host.git(&["add", "--all"]); + host.git(&["commit", "-qm", "initial remote content"]); + std::fs::write(host.root.join("host/selected.txt"), b"staged\nbase\n").unwrap(); + std::fs::write(host.root.join("host/unrelated.txt"), b"keep staged\n").unwrap(); + host.git(&["add", "--all"]); + std::fs::write(host.root.join("host/selected.txt"), b"staged\nworktree\n").unwrap(); + let index = std::fs::read(host.root.join("host/.git/index")).unwrap(); + + let snapshot = host.call( + "git.diff", + json!({"pathspecs": ["selected.txt"], "worktreeSnapshot": true}), + ); + let patch = snapshot["patch"].as_str().unwrap(); + assert!( + patch.contains("+staged") && patch.contains("+worktree"), + "{snapshot}" + ); + assert_eq!( + std::fs::read(host.root.join("host/.git/index")).unwrap(), + index + ); + let committed = host.call( + "git.write", + json!({"operation": "commit", "paths": ["selected.txt"], "message": "selected remote content"}), + ); + assert_eq!(committed["exitCode"], 0, "{committed}"); + assert_eq!( + host.git(&["show", "HEAD:selected.txt"])["stdout"], + "staged\nworktree\n" + ); + assert_eq!( + host.git(&["show", "HEAD:unrelated.txt"])["stdout"], + "base\n" + ); + assert_eq!( + host.git(&["diff", "--cached", "--name-only"])["stdout"], + "unrelated.txt\n" + ); + + let history = host.call( + "git.lineHistory", + json!({"path": "selected.txt", "content": "unsaved\nstaged\nworktree\n", "startLine": 3, "endLine": 3}), + ); + assert_eq!(history["commits"][0]["subject"], "selected remote content"); + assert_eq!(history["unmappedLines"], json!([])); + assert_eq!( + std::fs::read(host.root.join("host/selected.txt")).unwrap(), + b"staged\nworktree\n" + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0, + "index and source snapshots must stay on the workspace host" + ); + assert!(std::fs::read_dir(host.root.join("host/.git")) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("lithe-commit-"))); +} + +#[test] +fn remote_git_worktree_and_clone_destinations_use_the_host_filesystem() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "user.name", "Lithe Remote Test"]); + host.git(&["config", "user.email", "remote@example.invalid"]); + std::fs::write(host.root.join("host/tracked.txt"), b"host-only\n").unwrap(); + host.git(&["add", "tracked.txt"]); + host.git(&["commit", "-qm", "remote worktree source"]); + + let destination = host.root.join("placeholder/.worktrees/topic with spaces"); + let created = host.call( + "git.write", + json!({"operation": "createWorktree", "destination": destination, + "worktreeMode": "detached", "gitReference": { + "kind": "local", "fullName": "refs/heads/main", "shortName": "main" + }}), + ); + assert_eq!(created["exitCode"], 0, "{created}"); + assert!(host + .root + .join("host/.worktrees/topic with spaces/.git") + .is_file()); + let entries = host.call("git.worktrees", json!({})); + let entry = entries["worktrees"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["path"] == json!(destination)) + .expect("worktree paths must retain the client's workspace identity"); + assert_eq!(entry["isCurrent"], false); + assert_eq!(entry["isPrimary"], false); + for operation in ["lockWorktree", "unlockWorktree", "removeWorktree"] { + let result = host.call( + "git.write", + json!({"operation": operation, "destination": entry["path"]}), + ); + assert_eq!(result["exitCode"], 0, "{operation}: {result}"); + } + assert!(!host.root.join("host/.worktrees/topic with spaces").exists()); + + let cloned = host.call( + "git.write", + json!({"operation": "clone", "remote": host.root.join("host"), + "destination": host.root.join("placeholder/cloned project")}), + ); + assert_eq!(cloned["exitCode"], 0, "{cloned}"); + assert_eq!( + std::fs::read(host.root.join("host/cloned project/tracked.txt")).unwrap(), + b"host-only\n" + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0, + "remote Git must never create worktrees or clones in the placeholder" + ); +} + +#[test] +fn remote_git_configuration_reports_the_host_executable_and_sources() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "fetch.prune", "true"]); + let request = |command| -> Value { + serde_json::from_str(&crate::execute_json(&json!({ + "id": nonce(), "command": command, "timeoutMilliseconds": 10_000, + "remoteWorkspace": {"sessionId": host.id, "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")}, + // This client override must not be used on the workspace host. + "gitExecution": {"executable": "/fixture/client-only/git"}, + "payload": {"root": host.root.join("placeholder")} + }).to_string())).unwrap() + }; + let located = request("git.executable"); + assert_eq!(located["ok"], true, "{located}"); + let executable = located["data"]["executable"].as_str().unwrap(); + assert!(executable.starts_with('/'), "{located}"); + assert_ne!(executable, "/fixture/client-only/git"); + let inspected = request("git.executionInspect"); + assert_eq!(inspected["ok"], true, "{inspected}"); + assert_eq!(inspected["data"]["executable"], executable); + assert_eq!(inspected["data"]["version"], located["data"]["version"]); + let entry = inspected["data"]["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["key"] == "fetch.prune" && entry["scope"] == "local") + .unwrap(); + assert_eq!(entry["value"], "true"); + assert!(entry["origin"].as_str().unwrap().contains(".git/config")); + // Disconnect after a cached resolution: errors must still propagate instead + // of showing an empty setting or probing the client's selected executable. + lookup(&host.id).unwrap().close(); + let closed = request("git.executable"); + assert_eq!(closed["ok"], false, "{closed}"); +} + +#[test] +fn remote_workspace_file_limits_preserve_host_content_and_report_disconnects() { + use crate::remote::{workspace, workspace_io as fs}; + use std::io::Read; + + let host = HostFixture::new(); + let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(10_000)); + let workspace = serde_json::from_value(json!({"sessionId": host.id, + "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) + .unwrap(); + let _workspace = workspace::Scope::begin(Some(workspace), "git.status").unwrap(); + let local = host.root.join("placeholder"); + let remote = host.root.join("host"); + let content = vec![b'x'; 2 * 1024 * 1024 + 7]; + fs::write(local.join("file"), &content).unwrap(); + assert_eq!(fs::read(local.join("file")).unwrap(), content); + + let oversized = vec![b'y'; methods::MAX_TRANSFER_BYTES as usize + 1]; + assert_eq!( + fs::write(local.join("file"), &oversized) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!(std::fs::read(remote.join("file")).unwrap(), content); + + // A sparse file checks the total materialization budget without a large + // allocation, while streaming consumers can still inspect its contents. + let sparse = std::fs::File::create(remote.join("large")).unwrap(); + sparse.set_len(32 * 1024 * 1024 + 1).unwrap(); + assert_eq!( + fs::read(local.join("large")).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + let mut prefix = [1; 32]; + fs::File::open(local.join("large")) + .unwrap() + .read_exact(&mut prefix) + .unwrap(); + assert_eq!(prefix, [0; 32]); + + assert!(!fs::try_exists(local.join("absent")).unwrap()); + assert!(fs::is_dir(&local).unwrap()); + lookup(&host.id).unwrap().close(); + assert!(fs::try_exists(local.join("file")).is_err()); + assert!(fs::is_dir(&local).is_err()); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0); +} + +#[test] +fn captured_host_process_preserves_binary_streams_and_cleans_up_limit_failures() { + let host = HostFixture::new(); + let capture = |argv: &[&str], timeout: u64, limit: usize| { + let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); + crate::remote::workspace_host::capture_process( + serde_json::from_value(json!({ + "sessionId": host.id, "params": {"processId": 0, "argv": argv}, + "timeoutMilliseconds": timeout, "maxOutputBytes": limit + })) + .unwrap(), + ) + }; + let output = capture( + &[ + "/bin/sh", + "-c", + r"printf 'a\000b'; printf 'err' >&2; exit 7", + ], + 2_000, + 6, + ) + .unwrap(); + assert_eq!( + lithe_remote_protocol::methods::decode_bytes(output["stdout"].as_str().unwrap()).unwrap(), + b"a\0b" + ); + assert_eq!( + lithe_remote_protocol::methods::decode_bytes(output["stderr"].as_str().unwrap()).unwrap(), + b"err" + ); + assert_eq!(output["exitCode"], 7); + assert!(matches!( + capture(&["/bin/sh", "-c", "printf abcd; printf efgh >&2"], 2_000, 7) + .unwrap_err() + .code, + ErrorCode::ProcessFailed + )); + // The long-lived child has no natural completion within the test deadline; + // only the capture owner can terminate it when its budget expires. + assert!(matches!( + capture(&["/bin/sleep", "60"], 50, 128).unwrap_err().code, + ErrorCode::TimedOut + )); + let connection = lookup(&host.id).unwrap(); + let deadline = Instant::now() + STEP; + loop { + let state = connection + .session + .request_value(methods::SERVER_STATE, json!({}), STEP) + .unwrap(); + if state["processes"].as_array().unwrap().is_empty() { + break; + } + assert!( + Instant::now() < deadline, + "capture leaked a host process: {state}" + ); + std::thread::yield_now(); + } +} + +#[test] +fn native_plugin_upload_streams_beyond_one_frame_and_rejects_mismatch_without_publication() { + use sha2::{Digest, Sha256}; + use std::os::unix::fs::PermissionsExt; + let host = HostFixture::new(); + let source = host.root.join("native-package"); + let target = host.root.join("host/package"); + let content = vec![b'p'; methods::MAX_TRANSFER_BYTES as usize + 7]; + std::fs::write(&source, &content).unwrap(); + let digest = format!("{:x}", Sha256::digest(&content)); + let upload = |path: &std::path::Path, expected: &str| { + let request = serde_json::from_value(json!({"sessionId": host.id, + "sourcePath": source, "path": path, "sha256": expected, "mode": 0o500})) + .unwrap(); + super::super::upload::upload(request) + }; + let receipt = upload(&target, &digest).unwrap(); + assert_eq!(receipt["sha256"], digest); + assert_eq!(receipt["size"], content.len()); + assert_eq!(std::fs::read(&target).unwrap(), content); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o500 + ); + assert!( + upload(&target, &digest).is_err(), + "upload must not replace installed bytes" + ); + let bad = host.root.join("host/bad"); + assert!(upload(&bad, &"0".repeat(64)).is_err()); + assert!(!bad.exists()); + assert_eq!( + std::fs::read_dir(host.root.join("host")).unwrap().count(), + 1 + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0 + ); +} + +#[test] +fn plugin_stdio_uses_its_owned_host_and_reclaims_the_remote_process() { + use crate::lsp::interface::process::{ + LspProcessLauncher, LspProcessSpec, SystemProcessLauncher, + }; + use std::io::Read; + let host = HostFixture::new(); + let workspace = serde_json::from_value(json!({"sessionId": host.id, + "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) + .unwrap(); + let scope = super::super::workspace::Scope::begin(Some(workspace), "lsp.startServer").unwrap(); + let streams = SystemProcessLauncher + .launch(LspProcessSpec { + executable: "/bin/sh".into(), + arguments: vec!["-c".into(), "pwd >&2; cat".into()], + working_directory: host.root.join("placeholder"), + environment: Default::default(), + }) + .unwrap(); + // Later calls execute without a current workspace. The handle must retain + // its original connection instead of choosing a local or newly selected host. + drop(scope); + let input = b"Content-Length: 2\r\n\r\n{}"; + streams.handle.write_input(input).unwrap(); + streams.handle.close_input(); + let (done, completed) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut output = streams.output; + let mut errors = streams.errors; + let result = output + .read_to_end(&mut stdout) + .and_then(|_| errors.read_to_end(&mut stderr)); + let _ = done.send((result, stdout, stderr)); + }); + let result = completed.recv_timeout(STEP); + // Termination wakes readers on assertion failures and has its own deadline. + streams.handle.terminate(); + let outcome = match result { + Ok(result) => result, + Err(error) => { + if completed.recv_timeout(STEP).is_ok() { + reader.join().unwrap(); + } + panic!("remote stdio did not complete: {error}") + } + }; + reader.join().unwrap(); + let (io_result, stdout, stderr) = outcome; + io_result.expect("remote stdio must reach EOF without a transport error"); + assert_eq!(stdout, input); + assert_eq!( + String::from_utf8(stderr).unwrap().trim(), + host.root.join("host").to_str().unwrap() + ); + assert_eq!( + streams.handle.process_id(), + None, + "remote PIDs must not enter local process registries" + ); + assert_eq!(streams.handle.exit_status(), Some(Some(0))); +} + +#[test] +fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { + let host = HostFixture::new(); + let workspace = serde_json::from_value(json!({"sessionId": host.id, + "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) + .unwrap(); + let _scope = super::super::workspace::Scope::begin(Some(workspace), "lsp.startServer").unwrap(); + let owner = super::super::workspace::current().unwrap(); + let local = url::Url::from_file_path(host.root.join("placeholder/a space.java")) + .unwrap() + .to_string(); + let remote = url::Url::from_file_path(host.root.join("host/a space.java")) + .unwrap() + .to_string(); + let value = json!({"method": "textDocument/didOpen", "params": {"textDocument": {"uri": local, + "text": format!("// {local}"), "languageId": "java", "version": 1}}}); + let mapped: Value = serde_json::from_str( + &crate::lsp::interface::remote_process::map_message(&owner, &value.to_string(), true, None) + .unwrap(), + ) + .unwrap(); + assert_eq!(mapped["params"]["textDocument"]["uri"], remote); + assert_eq!( + mapped["params"]["textDocument"]["text"], + value["params"]["textDocument"]["text"] + ); + let edit = json!({"method": "workspace/applyEdit", "params": {"edit": {"changes": {remote.clone(): [{"newText": local}]}}}}); + let mapped: Value = serde_json::from_str( + &crate::lsp::interface::remote_process::map_message(&owner, &edit.to_string(), false, None) + .unwrap(), + ) + .unwrap(); + assert_eq!( + mapped["params"]["edit"]["changes"][&local][0]["newText"], + local + ); + let map = |value: &Value, outbound, method| -> Value { + serde_json::from_str( + &crate::lsp::interface::remote_process::map_message( + &owner, + &value.to_string(), + outbound, + method, + ) + .unwrap(), + ) + .unwrap() + }; + // JSON-RPC replies have no method. The originating request keeps plugin + // values opaque while standard LSP location results still get mapped. + let reply = json!({"id": 17, "result": {"uri": remote, "data": {"uri": remote}}}); + assert_eq!(map(&reply, false, Some("lithe/function/invoke")), reply); + let mapped = map(&reply, false, Some("textDocument/definition")); + assert_eq!(mapped["result"]["uri"], local); + assert_eq!(mapped["result"]["data"], reply["result"]["data"]); + for method in [ + "lithe/function/invoke", + "lithe/host/credentials/get", + "custom/lookup", + ] { + let request = json!({"method": method, "params": {"uri": local, "data": {"uri": local}}}); + assert_eq!(map(&request, true, None), request); + } + let request = json!({"method": "initialize", "params": {"rootUri": local, + "initializationOptions": {"uri": local}, "capabilities": {"experimental": {"uri": local}}}}); + let mapped = map(&request, true, None); + assert_eq!(mapped["params"]["rootUri"], remote); + assert_eq!( + mapped["params"]["initializationOptions"], + request["params"]["initializationOptions"] + ); + assert_eq!( + mapped["params"]["capabilities"], + request["params"]["capabilities"] + ); + let request = json!({"method": "lithe/vcs/status", "params": { + "root": {"path": host.root.join("placeholder")}, + "tool": {"executable": host.root.join("placeholder/tools/svn")}, + "paths": ["dir/file"], "uri": local}}); + let mapped = map(&request, true, None); + assert_eq!( + mapped["params"]["root"]["path"], + json!(host.root.join("host")) + ); + assert_eq!( + mapped["params"]["tool"]["executable"], + json!(host.root.join("host/tools/svn")) + ); + assert_eq!(mapped["params"]["paths"], request["params"]["paths"]); + assert_eq!(mapped["params"]["uri"], local); +} diff --git a/rust/lithe-core/src/remote/upload.rs b/rust/lithe-core/src/remote/upload.rs new file mode 100644 index 0000000000..b699f58f9f --- /dev/null +++ b/rust/lithe-core/src/remote/upload.rs @@ -0,0 +1,142 @@ +//! Streams a native source file to verified host staging without enlarging frames. +//! The caller owns package trust; this adapter owns transfer integrity and cleanup. + +use super::sessions::{lookup, request_error}; +use super::workspace_host::request_cancellable; +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_remote_protocol::methods; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::io::Read; +use std::path::Path; +use std::time::Duration; + +const STEP: Duration = Duration::from_secs(30); + +/// Native upload source and host destination, plus caller-verified expected hash. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Request { + session_id: String, + source_path: String, + path: String, + sha256: String, + mode: u32, +} + +/// Uses handshake facts without running another shell or using the local platform. +pub(crate) fn host_info(session_id: &str) -> Result { + let connection = lookup(session_id)?; + serde_json::to_value(&connection.session.hello().host).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid host capabilities") + .with_details(error.to_string()) + }) +} + +/// Uploads one bounded file; any failure aborts its connection-owned staging inode. +pub(crate) fn upload(request: Request) -> Result { + cancellation::check()?; + if !Path::new(&request.source_path).is_absolute() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source must be absolute", + )); + } + let mut source = std::fs::File::open(&request.source_path).map_err(source_error)?; + let metadata = source.metadata().map_err(source_error)?; + if !metadata.is_file() || metadata.len() > methods::MAX_UPLOAD_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source is not a bounded regular file", + )); + } + let connection = lookup(&request.session_id)?; + if !connection + .session + .hello() + .host + .features + .contains(&methods::Feature::Upload) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The host does not support staged uploads", + )); + } + let call = |method: &str, params: Value| { + cancellation::check()?; + request_cancellable(&connection.session, method, params, STEP) + .map_err(|error| request_error(&request.session_id, error)) + }; + let started = call( + methods::FS_UPLOAD_START, + json!({"path": request.path, "size": metadata.len(), "sha256": request.sha256, "mode": request.mode}), + )?; + let id = started["uploadId"] + .as_u64() + .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "Missing upload identity"))?; + let result = (|| { + let mut buffer = vec![0; methods::MAX_UPLOAD_CHUNK_BYTES]; + let mut offset = 0; + loop { + cancellation::check()?; + let count = source.read(&mut buffer).map_err(source_error)?; + if count == 0 { + break; + } + if offset + count as u64 > metadata.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source changed", + )); + } + let received = call( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": offset, "data": methods::encode_bytes(&buffer[..count])}), + )?; + offset += count as u64; + if received["offset"].as_u64() != Some(offset) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Unexpected upload acknowledgement", + )); + } + } + if offset != metadata.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source changed", + )); + } + let committed = call(methods::FS_UPLOAD_COMMIT, json!({"uploadId": id}))?; + if committed["size"].as_u64() != Some(offset) + || committed["sha256"].as_str() != Some(request.sha256.to_ascii_lowercase().as_str()) + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid upload receipt", + )); + } + Ok(committed) + })(); + if result.is_err() { + // Cleanup must run even when the parent Core operation is cancelled. + if let Err(error) = connection.session.request_value( + methods::FS_UPLOAD_ABORT, + json!({"uploadId": id}), + Duration::from_secs(2), + ) { + if !matches!(&error, lithe_remote_client::session::ClientError::Remote(remote) + if remote.code == lithe_remote_protocol::ErrorCode::NotFound) + { + eprintln!("remote upload abort failed; connection staging will expire"); + } + } + } + result +} + +fn source_error(error: std::io::Error) -> CoreError { + CoreError::new(ErrorCode::Unknown, "Could not read upload source") + .with_details(error.kind().to_string()) +} diff --git a/rust/lithe-core/src/remote/workspace.rs b/rust/lithe-core/src/remote/workspace.rs new file mode 100644 index 0000000000..2c6a34b8fc --- /dev/null +++ b/rust/lithe-core/src/remote/workspace.rs @@ -0,0 +1,170 @@ +//! Request-scoped host identity for existing workspace semantic engines. +//! +//! A remote request never falls back to the placeholder filesystem or local +//! executables. Git continues to own Git semantics; this boundary supplies IO. + +use super::sessions::{lookup, Connection}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Host and path identity supplied by the native workspace registry. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Workspace { + session_id: String, + local_root: PathBuf, + remote_root: PathBuf, +} + +#[derive(Clone)] +pub(crate) struct Host { + pub(crate) connection: Arc, + workspace: Workspace, +} + +thread_local! { static HOST: RefCell> = const { RefCell::new(None) }; } + +/// Restores the caller's host for nested Core requests and error paths. +pub(crate) struct Scope(Option); + +impl Scope { + pub(crate) fn begin(workspace: Option, command: &str) -> Result { + let host = workspace + .map(|workspace| { + if !(command.starts_with("git.") + || command.starts_with("build.") + || matches!( + command, + "workspace.repositories" + | "workspace.snapshot" + | "workspace.listDirectories" + | "workspace.search" + | "workspace.searchEverywhere" + | "file.read" + | "file.write" + | "workspace.searchIndex.warm" + | "workspace.searchIndex.update" + | "workspace.searchIndex.invalidate" + | "workspace.replacePreview" + | "runConfig.selectJava" + | "lsp.startServer" + )) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "This command has no remote workspace adapter", + )); + } + if !valid_root(&workspace.local_root) || !valid_root(&workspace.remote_root) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Workspace roots must be absolute without parent components", + )); + } + Ok(Host { + connection: lookup(&workspace.session_id)?, + workspace, + }) + }) + .transpose()?; + Ok(Self(HOST.with(|current| current.replace(host)))) + } +} + +/// Installs an already validated session owner on a backend worker. +pub(crate) fn enter(host: Option) -> Scope { + Scope(HOST.with(|current| current.replace(host))) +} + +fn valid_root(path: &Path) -> bool { + path.is_absolute() + && !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) +} + +impl Drop for Scope { + fn drop(&mut self) { + HOST.with(|current| current.replace(self.0.take())); + } +} + +pub(crate) fn current() -> Option { + HOST.with(|host| host.borrow().clone()) +} + +/// Whether filesystem and tool operations belong to a connected workspace host. +pub(crate) fn is_remote() -> bool { + current().is_some() +} + +impl Host { + /// Maps only file URIs contained by this workspace, preserving URI escaping. + pub(crate) fn map_uri(&self, value: &str, outbound: bool) -> Option { + let uri = url::Url::parse(value).ok()?; + if uri.scheme() != "file" + || uri + .host_str() + .is_some_and(|host| !host.is_empty() && host != "localhost") + { + return None; + } + let path = uri.to_file_path().ok()?; + let mapped = if outbound { + self.remote_path(&path) + } else { + self.local_path(&path) + }; + if mapped == path { + return None; + } + url::Url::from_file_path(mapped) + .ok() + .map(|uri| uri.to_string()) + } + pub(crate) fn remote_path(&self, path: &Path) -> PathBuf { + if path == self.workspace.local_root { + return self.workspace.remote_root.clone(); + } + path.strip_prefix(&self.workspace.local_root) + .map(|suffix| self.workspace.remote_root.join(suffix)) + .unwrap_or_else(|_| path.to_owned()) + } + + pub(super) fn local_path(&self, path: &Path) -> PathBuf { + if path == self.workspace.remote_root { + return self.workspace.local_root.clone(); + } + path.strip_prefix(&self.workspace.remote_root) + .map(|suffix| self.workspace.local_root.join(suffix)) + .unwrap_or_else(|_| path.to_owned()) + } + + pub(super) fn identity(&self) -> &str { + &self.workspace.session_id + } +} + +/// Projects host paths back onto the native workspace's URL identity. +pub(crate) fn local_path(path: &Path) -> PathBuf { + current() + .map(|host| host.local_path(path)) + .unwrap_or_else(|| path.to_owned()) +} + +/// Translates a schema-owned path to its host; opaque argv and text stay untouched. +pub(crate) fn remote_path(path: &Path) -> PathBuf { + current() + .map(|host| host.remote_path(path)) + .unwrap_or_else(|| path.to_owned()) +} + +/// Includes host identity in process-local repository coordination keys. +pub(crate) fn identity_key(path: &Path) -> PathBuf { + current() + .map(|host| PathBuf::from(host.identity()).join(path.strip_prefix("/").unwrap_or(path))) + .unwrap_or_else(|| path.to_owned()) +} diff --git a/rust/lithe-core/src/remote/workspace_host.rs b/rust/lithe-core/src/remote/workspace_host.rs new file mode 100644 index 0000000000..1998032c48 --- /dev/null +++ b/rust/lithe-core/src/remote/workspace_host.rs @@ -0,0 +1,572 @@ +//! Product access to the existing lightweight workspace host protocol. +//! +//! Streaming calls own their resource and subscription together. Cancellation, +//! failed creation, and caller disconnect all close the remote process tree; +//! ordinary host requests cannot create an unobserved streaming resource. + +use super::sessions::{lookup, request_error}; +use crate::git::execution_events; +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_remote_client::session::{ClientError, Event, RemoteSession}; +use lithe_remote_protocol::methods; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const EVENT_POLL: Duration = Duration::from_millis(20); +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); +const EARLY_EVENT_LIMIT: usize = 8 * 1024 * 1024; + +/// Waits for finite host work while preserving the owning Core cancellation. +pub(crate) fn request_cancellable( + session: &RemoteSession, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + let pending = session.begin_request_value(method, params, timeout)?; + loop { + if cancellation::check().is_err() { + return Err(ClientError::Protocol("Operation was cancelled".into())); + } + if let Some(value) = pending.poll(EVENT_POLL)? { + return Ok(value); + } + } +} + +/// One host operation. Parameters retain the versioned remote protocol shape. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Request { + session_id: String, + #[serde(default)] + method: String, + params: Value, + /// Optional process runtime bound; cleanup remains owned after expiry. + #[serde(default)] + timeout_milliseconds: Option, +} + +/// Forwards finite host operations and controls of already observed resources. +pub(crate) fn request(request: Request) -> Result { + if !allowed_method(&request.method) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "This host operation requires a resource owner", + )); + } + cancellation::check()?; + let connection = lookup(&request.session_id)?; + let timeout = if request.method == methods::ENVIRONMENT_RESOLVE { + // Interactive and non-interactive login probes each have a host deadline. + Duration::from_secs(60) + } else { + REQUEST_TIMEOUT + }; + let result = request_cancellable( + &connection.session, + &request.method, + request.params, + timeout, + ); + cancellation::check()?; + result.map_err(|error| request_error(&request.session_id, error)) +} + +fn allowed_method(method: &str) -> bool { + matches!( + method, + methods::FS_STAT + | methods::FS_READ_DIR + | methods::FS_READ_FILE + | methods::FS_WRITE_FILE + | methods::FS_CREATE_DIR + | methods::FS_REMOVE + | methods::FS_RENAME + | methods::FS_COPY + | methods::FS_TRASH + | methods::FS_REAL_PATH + | methods::FS_SET_PERMISSIONS + | methods::FS_VOLUME_INFO + | methods::FS_TEMP_DIRECTORY + | methods::FS_UPLOAD_START + | methods::FS_UPLOAD_CHUNK + | methods::FS_UPLOAD_COMMIT + | methods::FS_UPLOAD_ABORT + | methods::ENVIRONMENT_RESOLVE + | methods::PROCESS_WRITE_STDIN + | methods::PROCESS_CLOSE_STDIN + | methods::PROCESS_KILL + | methods::TERMINAL_WRITE + | methods::TERMINAL_RESIZE + | methods::TERMINAL_CLOSE + | methods::PORTS_LISTENING + ) +} + +/// Starts a process with a subscription installed before any output can arrive. +pub(crate) fn run_process(request: Request) -> Result { + run_resource(request, "process", methods::PROCESS_SPAWN) +} + +/// Starts a PTY with the same subscription, cancellation and cleanup ownership. +pub(crate) fn run_terminal(request: Request) -> Result { + run_resource(request, "terminal", methods::TERMINAL_OPEN) +} + +/// Owns a recursive watch until cancellation, disconnect, or root removal. +pub(crate) fn run_watch(request: Request) -> Result { + run_resource(request, "watch", methods::WATCH_START) +} + +/// Bounded byte capture for tool probes; long-lived backends use streaming calls. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct CaptureRequest { + session_id: String, + params: methods::SpawnParams, + timeout_milliseconds: u64, + max_output_bytes: usize, +} + +/// Captures one process without leaking a child on timeout or excessive output. +pub(crate) fn capture_process(request: CaptureRequest) -> Result { + if !(1..=30_000).contains(&request.timeout_milliseconds) + || !(1..=methods::MAX_TRANSFER_BYTES as usize).contains(&request.max_output_bytes) + || request.params.pipe_stdin + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid host capture limits", + )); + } + let connection = lookup(&request.session_id)?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = run_observing( + &connection.session, + Request { + session_id: request.session_id, + method: String::new(), + params: serde_json::to_value(request.params).expect("spawn parameters encode"), + timeout_milliseconds: None, + }, + "process", + methods::PROCESS_SPAWN, + None, + Some(Instant::now() + Duration::from_millis(request.timeout_milliseconds)), + &mut |event| { + if event.event == methods::EVENT_PROCESS_OUTPUT { + let output: methods::ProcessOutputEvent = event.decode().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid host process output") + .with_details(error.to_string()) + })?; + let bytes = methods::decode_bytes(&output.data).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid host process bytes") + .with_details(error.to_string()) + })?; + if stdout.len() + stderr.len() + bytes.len() > request.max_output_bytes { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Host process output exceeded its limit", + )); + } + match output.stream { + methods::OutputStream::Stdout => stdout.extend_from_slice(&bytes), + methods::OutputStream::Stderr => stderr.extend_from_slice(&bytes), + } + } + Ok(()) + }, + )?; + Ok( + json!({"stdout": methods::encode_bytes(&stdout), "stderr": methods::encode_bytes(&stderr), + "exitCode": exit["exitCode"], "signal": exit["signal"]}), + ) +} + +struct Resource<'a> { + session: &'a RemoteSession, + family: &'static str, + id: u64, + /// A lost spawn response can still mean the host created the resource. + creation_attempted: bool, +} + +impl Drop for Resource<'_> { + fn drop(&mut self) { + // Drop the receiver before the cleanup request: a full subscriber must + // never block the protocol reader from delivering the close response. + self.session.unsubscribe(self.family, self.id); + if self.creation_attempted { + let (method, params) = match self.family { + "terminal" => (methods::TERMINAL_CLOSE, json!({"terminalId": self.id})), + "watch" => (methods::WATCH_STOP, json!({"watchId": self.id})), + _ => ( + methods::PROCESS_KILL, + json!({"processId": self.id, "force": true}), + ), + }; + let _ = self.session.request_value(method, params, CLEANUP_TIMEOUT); + } + } +} + +fn run_resource(request: Request, family: &'static str, method: &str) -> Result { + cancellation::check()?; + let connection = lookup(&request.session_id)?; + run_on_session(&connection.session, request, family, method) +} + +fn run_on_session( + session: &RemoteSession, + request: Request, + family: &'static str, + method: &str, +) -> Result { + let deadline = request + .timeout_milliseconds + .map(|timeout| Instant::now() + Duration::from_millis(timeout.min(86_400_000))); + run_observing( + session, + request, + family, + method, + None, + deadline, + &mut |event| { + execution_events::emit(json!({ + "type": "remote", "event": event.event, "params": event.params, + })); + Ok(()) + }, + ) +} + +/// Runs an owned host process with byte-preserving output and optional input. +pub(crate) fn process_observing( + session: &RemoteSession, + params: methods::SpawnParams, + input: Option<&[u8]>, + observer: &mut impl FnMut(Event) -> Result<(), CoreError>, +) -> Result { + run_observing( + session, + Request { + session_id: String::new(), + method: String::new(), + params: serde_json::to_value(params).expect("spawn parameters encode"), + timeout_milliseconds: None, + }, + "process", + methods::PROCESS_SPAWN, + input, + None, + observer, + ) +} + +fn run_observing( + session: &RemoteSession, + request: Request, + family: &'static str, + method: &str, + input: Option<&[u8]>, + deadline: Option, + observer: &mut impl FnMut(Event) -> Result<(), CoreError>, +) -> Result { + let id = session.allocate_id(); + let key = format!("{family}Id"); + let mut params = request.params; + params + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Host parameters must be an object", + ) + })? + .insert(key.clone(), json!(id)); + // Validate before subscribing or sending. The host allocates IDs; callers + // cannot steal another resource by supplying an ID in their parameters. + match family { + "terminal" => { + decode::(params.clone())?; + } + "watch" => { + decode::(params.clone())?; + } + _ => { + decode::(params.clone())?; + } + } + let resource = Resource { + session, + family, + id, + creation_attempted: true, + }; + let events = session.subscribe(family, id); + let pending = session + .begin_request_value(method, params, REQUEST_TIMEOUT) + .map_err(|error| request_error(&request.session_id, error))?; + let mut early = VecDeque::new(); + let mut early_bytes = 0usize; + let started = loop { + cancellation::check()?; + check_deadline(deadline)?; + if let Some(value) = pending + .poll(EVENT_POLL) + .map_err(|error| request_error(&request.session_id, error))? + { + break value; + } + while let Ok(event) = events.try_recv() { + early_bytes = early_bytes.saturating_add(event.params.to_string().len()); + if early_bytes > EARLY_EVENT_LIMIT { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Host startup output exceeded its limit", + )); + } + early.push_back(event); + } + }; + observer(Event { + event: format!("{family}.started"), + params: json!({key: id, "pid": started.get("pid")}), + seq: None, + })?; + let outcome = receive_events( + &resource, + &events, + early, + &request.session_id, + input, + deadline, + observer, + ); + // See Resource::drop: remove backpressure before trying to stop the tree. + drop(events); + drop(resource); + outcome +} + +fn receive_events( + resource: &Resource<'_>, + events: &Receiver, + mut early: VecDeque, + session_id: &str, + mut input: Option<&[u8]>, + deadline: Option, + observer: &mut impl FnMut(Event) -> Result<(), CoreError>, +) -> Result { + let exit = format!("{}.exit", resource.family); + let mut writing = None; + let mut write_count = 0usize; + loop { + cancellation::check()?; + check_deadline(deadline)?; + let mut observed = 0; + while let Ok(event) = early + .pop_front() + .map(Ok) + .unwrap_or_else(|| events.try_recv()) + { + validate_event(resource, &event)?; + let finished = event.event == exit || watch_ended(&event); + let result = event.params.clone(); + observer(event)?; + if finished { + return Ok(result); + } + observed += 1; + if observed >= 32 { + break; + } + } + if let Some(pending) = &writing { + match lithe_remote_client::session::PendingRequest::poll(pending, Duration::ZERO) { + Ok(Some(_)) => { + input = if write_count == 0 { + None + } else { + input.map(|bytes| &bytes[write_count..]) + }; + writing = None; + } + Ok(None) => {} + Err(ClientError::Remote(error)) + if error.code == lithe_remote_protocol::ErrorCode::Busy => + { + writing = None + } + // A child can finish between its input and exit event. Drain + // the authoritative exit before treating a closed pipe as failure. + Err(ClientError::Remote(error)) + if matches!( + error.code, + lithe_remote_protocol::ErrorCode::UnknownResource + | lithe_remote_protocol::ErrorCode::InvalidRequest + ) => + { + input = None; + writing = None; + } + Err(error) => return Err(request_error(session_id, error)), + } + } + if let Some(remaining) = input.filter(|_| writing.is_none()) { + let count = remaining.len().min(32 * 1024); + let (method, params) = if count == 0 { + ( + methods::PROCESS_CLOSE_STDIN, + json!({"processId": resource.id}), + ) + } else { + ( + methods::PROCESS_WRITE_STDIN, + json!({"processId": resource.id, + "data": methods::encode_bytes(&remaining[..count])}), + ) + }; + write_count = count; + writing = Some( + resource + .session + .begin_request_value(method, params, REQUEST_TIMEOUT) + .map_err(|error| request_error(session_id, error))?, + ); + } + match events.recv_timeout(EVENT_POLL) { + Ok(event) => { + validate_event(resource, &event)?; + let finished = event.event == exit || watch_ended(&event); + let result = event.params.clone(); + observer(event)?; + if finished { + return Ok(result); + } + } + Err(RecvTimeoutError::Timeout) if !resource.session.is_closed() => {} + Err(_) => { + return Err(request_error( + session_id, + ClientError::Disconnected { + diagnostics: String::new(), + }, + )) + } + } + } +} + +fn check_deadline(deadline: Option) -> Result<(), CoreError> { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(CoreError::new( + ErrorCode::TimedOut, + "Host process timed out", + )) + } else { + Ok(()) + } +} + +fn watch_ended(event: &Event) -> bool { + event.event == methods::EVENT_WATCH_ERROR && event.params["code"] == "ROOT_REMOVED" +} + +fn validate_event(resource: &Resource<'_>, event: &Event) -> Result<(), CoreError> { + if event.resource() != Some((resource.family, resource.id)) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Host resource identity changed", + )); + } + let valid_data = |data: &str| { + methods::decode_bytes(data) + .is_ok_and(|bytes| bytes.len() <= methods::MAX_TRANSFER_BYTES as usize) + }; + let valid = match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => event + .decode::() + .is_ok_and(|event| valid_data(&event.data)), + methods::EVENT_PROCESS_EXIT => event.decode::().is_ok(), + methods::EVENT_TERMINAL_OUTPUT => event + .decode::() + .is_ok_and(|event| valid_data(&event.data)), + methods::EVENT_TERMINAL_EXIT => event.decode::().is_ok(), + methods::EVENT_WATCH_CHANGED => event.decode::().is_ok(), + methods::EVENT_WATCH_ERROR => event.decode::().is_ok(), + _ => false, + }; + if !valid { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid host resource event", + )); + } + Ok(()) +} + +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid host resource parameters", + ) + .with_details(error.to_string()) + }) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod request_tests { + use super::*; + + #[test] + fn finite_requests_cannot_create_unobserved_resources_or_change_session_delivery() { + for method in [ + methods::PROCESS_SPAWN, + methods::TERMINAL_OPEN, + methods::WALK_START, + methods::WATCH_START, + methods::TUNNEL_OPEN, + methods::SERVER_SHUTDOWN, + methods::SERVER_ACK, + methods::SERVER_RESUME, + ] { + assert!(!allowed_method(method), "{method}"); + } + for method in [ + methods::FS_STAT, + methods::ENVIRONMENT_RESOLVE, + methods::PROCESS_WRITE_STDIN, + methods::PROCESS_KILL, + methods::TERMINAL_RESIZE, + methods::TERMINAL_CLOSE, + ] { + assert!(allowed_method(method), "{method}"); + } + } + + #[test] + fn host_request_rejects_unowned_operations_before_looking_up_a_connection() { + let result = request(Request { + session_id: "missing-session".into(), + method: methods::TERMINAL_OPEN.into(), + params: json!({}), + timeout_milliseconds: None, + }); + assert!(matches!( + result.unwrap_err().code, + ErrorCode::InvalidRequest + )); + } +} diff --git a/rust/lithe-core/src/remote/workspace_host/tests.rs b/rust/lithe-core/src/remote/workspace_host/tests.rs new file mode 100644 index 0000000000..b3223ee777 --- /dev/null +++ b/rust/lithe-core/src/remote/workspace_host/tests.rs @@ -0,0 +1,314 @@ +//! Socket-level regressions for product resource ownership and event ordering. + +#![cfg(unix)] + +use super::*; +use lithe_remote_client::ConnectOptions; +use lithe_remote_protocol::frame::{read_frame, write_frame}; +use lithe_remote_protocol::{Message, PREAMBLE}; +use std::io::Write; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; +use std::sync::{mpsc, Arc, Mutex}; + +const STEP: Duration = Duration::from_secs(3); + +/// A real framed session with a deterministic peer. Socket deadlines and an +/// explicit completion signal bound failed assertions and thread cleanup. +struct Peer { + session: RemoteSession, + socket: UnixStream, + completed: mpsc::Receiver>, + thread: Option>, +} + +impl Peer { + fn new(exit: bool) -> Self { + Self::with_watch(exit, false) + } + + fn with_watch(exit: bool, watch: bool) -> Self { + let (client, mut server) = UnixStream::pair().unwrap(); + server.set_read_timeout(Some(STEP)).unwrap(); + server.set_write_timeout(Some(STEP)).unwrap(); + let socket = client.try_clone().unwrap(); + let (done, completed) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let mut seen = Vec::new(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + server.write_all(PREAMBLE).unwrap(); + while let Ok(Some(frame)) = read_frame(&mut server) { + let Message::Request { id, method, params } = + serde_json::from_slice(&frame).unwrap() + else { + continue; + }; + seen.push(method.clone()); + let result = match method.as_str() { + methods::SERVER_HELLO => json!({ + "protocolVersion": 1, "serverVersion": "test", + "host": {"os": "linux", "arch": "x86_64", "hostName": "test-host", + "userName": "dev", "homeDirectory": "/home/dev", "defaultShell": "/bin/sh", + "pathSeparator": "/", "process": {"orphanProtection": "parentDeathSignal"}, + "watch": {"backend": "inotify", "directoryLimit": 128}, "features": []}, + "session": {"persistent": false, "resumed": false, "nextResourceId": 7, "lastEventSeq": 0} + }), + methods::SERVER_RESUME => json!({"truncated": false}), + methods::PROCESS_SPAWN => { + // A fast child writes and exits before spawn's reply. + // The resource subscription must already exist. + let process = params["processId"].as_u64().unwrap(); + assert_eq!(process, 7); + send( + &mut server, + Message::Event { + event: methods::EVENT_PROCESS_OUTPUT.into(), + params: json!({"processId": process, "stream": "stdout", "data": methods::encode_bytes(b"early\0output")}), + seq: None, + }, + ); + if exit { + send( + &mut server, + Message::Event { + event: methods::EVENT_PROCESS_EXIT.into(), + params: json!({"processId": process, "exitCode": 19, "signal": null}), + seq: None, + }, + ); + } + json!({"pid": 101}) + } + methods::WATCH_START if watch => { + assert_eq!(params["watchId"], 7); + send( + &mut server, + Message::Event { + event: methods::EVENT_WATCH_CHANGED.into(), + params: json!({"watchId": 7, "changes": [{"path": "/workspace/a", "kind": "modified"}], "overflow": true}), + seq: None, + }, + ); + if exit { + send( + &mut server, + Message::Event { + event: methods::EVENT_WATCH_ERROR.into(), + params: json!({"watchId": 7, "code": "ROOT_REMOVED", "message": "root moved"}), + seq: None, + }, + ); + } + json!({"watchedDirectories": 1}) + } + methods::PROCESS_KILL | methods::WATCH_STOP => { + assert_eq!( + params, + if watch { + json!({"watchId": 7}) + } else { + json!({"processId": 7, "force": true}) + } + ); + send(&mut server, Message::success(id, Value::Null)); + break; + } + _ => panic!("unexpected method {method}"), + }; + send(&mut server, Message::success(id, result)); + } + })); + // Signal before propagating a peer assertion so teardown can join. + let _ = done.send(seen); + if let Err(error) = result { + std::panic::resume_unwind(error); + } + }); + let (session, events) = RemoteSession::from_streams( + client.try_clone().unwrap(), + client, + &ConnectOptions { + handshake_timeout: STEP, + ..ConnectOptions::default() + }, + ) + .unwrap(); + drop(events); + Self { + session, + socket, + completed, + thread: Some(thread), + } + } + + fn finish(&mut self) -> Vec { + let seen = self + .completed + .recv_timeout(STEP) + .expect("peer must finish within its socket deadline"); + self.thread.take().unwrap().join().unwrap(); + seen + } +} + +#[test] +fn watch_changes_before_start_reply_are_delivered_and_removed_root_stops_subscription() { + let mut peer = Peer::with_watch(true, true); + let _scope = cancellation::Scope::begin(Some("remote-watch-root".into()), Some(3_000)); + let observed = Arc::new(Mutex::new(Vec::::new())); + let records = Arc::clone(&observed); + let sink = Arc::new(move |raw: &str| { + records + .lock() + .unwrap() + .push(serde_json::from_str(raw).unwrap()); + }); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + Request { + session_id: "socket-test".into(), + method: String::new(), + timeout_milliseconds: None, + params: json!({"root": "/workspace"}), + }, + "watch", + methods::WATCH_START, + ) + }) + .unwrap(); + assert_eq!(result["code"], "ROOT_REMOVED"); + let records = observed.lock().unwrap(); + assert_eq!( + records + .iter() + .map(|value| value["event"].as_str().unwrap()) + .collect::>(), + ["watch.started", "watch.changed", "watch.error"] + ); + assert_eq!(records[1]["params"]["overflow"], true); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::WATCH_STOP) + ); +} + +#[test] +fn cancellation_of_a_watch_stops_its_host_resource_before_returning() { + let mut peer = Peer::with_watch(false, true); + let _scope = cancellation::Scope::begin(Some("remote-watch-cancel".into()), Some(3_000)); + let sink = Arc::new(|raw: &str| { + let event: Value = serde_json::from_str(raw).unwrap(); + if event["event"] == "watch.started" { + assert!(cancellation::cancel("remote-watch-cancel")); + } + }); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + Request { + session_id: "socket-test".into(), + method: String::new(), + timeout_milliseconds: None, + params: json!({"root": "/workspace"}), + }, + "watch", + methods::WATCH_START, + ) + }); + assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::WATCH_STOP) + ); +} + +impl Drop for Peer { + fn drop(&mut self) { + let _ = self.socket.shutdown(Shutdown::Both); + if self.thread.is_some() && self.completed.recv_timeout(STEP + STEP).is_ok() { + let _ = self.thread.take().unwrap().join(); + } + } +} + +fn send(socket: &mut UnixStream, message: Message) { + write_frame(socket, &serde_json::to_vec(&message).unwrap()).unwrap(); + socket.flush().unwrap(); +} + +fn spawn_request() -> Request { + Request { + session_id: "socket-test".into(), + method: String::new(), + timeout_milliseconds: None, + params: json!({"argv": ["test-tool"], "cwd": "/workspace", "processId": 999}), + } +} + +#[test] +fn early_binary_output_and_exit_are_ordered_after_started_and_tree_is_cleaned() { + let mut peer = Peer::new(true); + let observed = Arc::new(Mutex::new(Vec::::new())); + let records = Arc::clone(&observed); + let sink = Arc::new(move |raw: &str| { + records + .lock() + .unwrap() + .push(serde_json::from_str(raw).unwrap()) + }); + let _scope = cancellation::Scope::begin(Some("remote-early-output".into()), Some(3_000)); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + spawn_request(), + "process", + methods::PROCESS_SPAWN, + ) + }) + .unwrap(); + assert_eq!(result["exitCode"], 19); + let records = observed.lock().unwrap(); + assert_eq!( + records + .iter() + .map(|value| value["event"].as_str().unwrap()) + .collect::>(), + ["process.started", "process.output", "process.exit"] + ); + assert_eq!(records[0]["params"]["processId"], 7); + assert_eq!( + methods::decode_bytes(records[1]["params"]["data"].as_str().unwrap()).unwrap(), + b"early\0output" + ); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::PROCESS_KILL) + ); +} + +#[test] +fn cancellation_after_started_kills_the_owned_tree_before_returning() { + let mut peer = Peer::new(false); + let _scope = cancellation::Scope::begin(Some("remote-cancel-resource".into()), Some(3_000)); + let sink = Arc::new(|raw: &str| { + let event: Value = serde_json::from_str(raw).unwrap(); + if event["event"] == "process.started" { + assert!(cancellation::cancel("remote-cancel-resource")); + } + }); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + spawn_request(), + "process", + methods::PROCESS_SPAWN, + ) + }); + assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::PROCESS_KILL) + ); +} diff --git a/rust/lithe-core/src/remote/workspace_io.rs b/rust/lithe-core/src/remote/workspace_io.rs new file mode 100644 index 0000000000..b6a101590a --- /dev/null +++ b/rust/lithe-core/src/remote/workspace_io.rs @@ -0,0 +1,624 @@ +//! Filesystem adapter for workspace engines, with no local fallback on remote errors. + +use super::workspace::{self, Host}; +use lithe_remote_client::session::ClientError; +use lithe_remote_protocol::{methods, ErrorCode}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// Materialized workspace reads share the editor's 32 MiB budget. Streaming +// consumers (for example snapshot hashing) can still read larger files. +const MAX_READ_BYTES: u64 = 32 * 1024 * 1024; + +fn rpc(host: &Host, method: &str, params: Value) -> io::Result { + crate::protocol::cancellation::check().map_err(|error| io::Error::other(error.message))?; + let result = super::workspace_host::request_cancellable( + &host.connection.session, + method, + params, + Duration::from_secs(30), + ); + crate::protocol::cancellation::check() + .map_err(|error| io::Error::new(io::ErrorKind::Interrupted, error.message))?; + let value = result.map_err(|error| { + let kind = match &error { + ClientError::Remote(error) => match error.code { + ErrorCode::NotFound => io::ErrorKind::NotFound, + ErrorCode::AlreadyExists => io::ErrorKind::AlreadyExists, + ErrorCode::PermissionDenied => io::ErrorKind::PermissionDenied, + ErrorCode::NotADirectory => io::ErrorKind::NotADirectory, + ErrorCode::Conflict => io::ErrorKind::WouldBlock, + _ => io::ErrorKind::Other, + }, + ClientError::Timeout => io::ErrorKind::TimedOut, + _ => io::ErrorKind::ConnectionAborted, + }; + io::Error::new(kind, error) + })?; + serde_json::from_value(value).map_err(io::Error::other) +} + +fn path(host: &Host, path: &Path) -> io::Result { + host.remote_path(path) + .to_str() + .map(str::to_owned) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Remote paths must be UTF-8")) +} + +/// Metadata retaining the host's file kind and permissions. +pub(crate) struct Metadata { + kind: methods::FileKind, + size: u64, + mode: u32, + modified: Option, +} +impl Metadata { + /// Size in bytes as reported by the owning filesystem. + pub(crate) fn len(&self) -> u64 { + self.size + } + /// Whether the entry itself is a regular file. + pub(crate) fn is_file(&self) -> bool { + self.kind == methods::FileKind::File + } + /// Whether the entry itself is a directory. + pub(crate) fn is_dir(&self) -> bool { + self.kind == methods::FileKind::Directory + } + /// Kind of the entry itself, without following symbolic links. + pub(crate) fn file_type(&self) -> FileType { + FileType(self.kind) + } + /// POSIX mode used by guarded Git snapshots. + pub(crate) fn mode(&self) -> u32 { + self.mode + } + /// Host modification time; absent timestamps are not treated as fresh output. + pub(crate) fn modified(&self) -> io::Result { + self.modified.ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "Modification time is unavailable", + ) + }) + } +} + +/// Filesystem entry kind independent of the client operating system. +pub(crate) struct FileType(methods::FileKind); +impl FileType { + /// Whether the entry is a regular file, without following links. + pub(crate) fn is_file(&self) -> bool { + self.0 == methods::FileKind::File + } + /// Whether the entry is a directory, without following links. + pub(crate) fn is_dir(&self) -> bool { + self.0 == methods::FileKind::Directory + } + /// Whether the entry is a symbolic link. + pub(crate) fn is_symlink(&self) -> bool { + self.0 == methods::FileKind::Symlink + } +} + +fn native_metadata(metadata: std::fs::Metadata) -> Metadata { + let kind = if metadata.is_dir() { + methods::FileKind::Directory + } else if metadata.is_file() { + methods::FileKind::File + } else if metadata.is_symlink() { + methods::FileKind::Symlink + } else { + methods::FileKind::Other + }; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() + }; + #[cfg(not(unix))] + let mode = 0; + Metadata { + kind, + size: metadata.len(), + mode, + modified: metadata.modified().ok(), + } +} + +fn remote_metadata(metadata: methods::FileMetadata) -> Metadata { + Metadata { + kind: metadata.kind, + size: metadata.size, + mode: metadata.mode, + modified: metadata.modified_ms.and_then(|millis| { + let offset = Duration::from_millis(millis.unsigned_abs()); + if millis >= 0 { + UNIX_EPOCH.checked_add(offset) + } else { + UNIX_EPOCH.checked_sub(offset) + } + }), + } +} + +/// Reads metadata of the entry itself on the workspace host. +pub(crate) fn symlink_metadata(path_value: impl AsRef) -> io::Result { + let path_value = path_value.as_ref(); + match workspace::current() { + Some(host) => rpc::( + &host, + methods::FS_STAT, + json!({"path": path(&host, path_value)?}), + ) + .map(remote_metadata), + None => std::fs::symlink_metadata(path_value).map(native_metadata), + } +} + +/// Reads target metadata, resolving symbolic links on the owning host. +pub(crate) fn metadata(path_value: impl AsRef) -> io::Result { + if workspace::is_remote() { + let value = symlink_metadata(path_value.as_ref())?; + if value.file_type().is_symlink() { + return symlink_metadata(canonicalize(path_value)?); + } + Ok(value) + } else { + std::fs::metadata(path_value).map(native_metadata) + } +} + +/// Tests existence without treating host failures as a missing path. +pub(crate) fn try_exists(path_value: impl AsRef) -> io::Result { + match metadata(path_value) { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} +/// Tests whether a path is a directory without suppressing host failures. +pub(crate) fn is_dir(path_value: impl AsRef) -> io::Result { + match metadata(path_value) { + Ok(value) => Ok(value.is_dir()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} +/// Tests whether a path is a regular file on its owning filesystem. +pub(crate) fn is_file(path_value: impl AsRef) -> io::Result { + match metadata(path_value) { + Ok(value) => Ok(value.is_file()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +/// Resolves links while preserving the native workspace URL identity. +pub(crate) fn canonicalize(path_value: impl AsRef) -> io::Result { + let path_value = path_value.as_ref(); + match workspace::current() { + Some(host) => { + let result: methods::RealPathResult = rpc( + &host, + methods::FS_REAL_PATH, + json!({"path": path(&host, path_value)?}), + )?; + Ok(host.local_path(Path::new(&result.path))) + } + None => path_value.canonicalize(), + } +} + +/// Reads a link's stored target without resolving it. +pub(crate) fn read_link(path_value: impl AsRef) -> io::Result { + match workspace::current() { + Some(host) => { + let metadata: methods::FileMetadata = rpc( + &host, + methods::FS_STAT, + json!({"path": path(&host, path_value.as_ref())?}), + )?; + metadata.symlink_target.map(PathBuf::from).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Path is not a readable symbolic link", + ) + }) + } + None => std::fs::read_link(path_value), + } +} + +/// Streaming file reader; remote reads use bounded chunks rather than mirroring a file. +pub(crate) struct File(FileReader); +enum FileReader { + Local(std::fs::File), + Remote { + host: Host, + path: String, + metadata: Metadata, + offset: u64, + /// Version captured by the first read, not a separate path stat. + version: Option, + eof: bool, + }, +} +impl File { + /// Opens a regular file on its owning host. + pub(crate) fn open(path_value: impl AsRef) -> io::Result { + match workspace::current() { + Some(host) => { + let metadata = metadata(path_value.as_ref())?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Expected a regular file", + )); + } + let path = path(&host, path_value.as_ref())?; + Ok(Self(FileReader::Remote { + host, + path, + metadata, + offset: 0, + version: None, + eof: false, + })) + } + None => std::fs::File::open(path_value).map(|file| Self(FileReader::Local(file))), + } + } + /// Metadata of this opened file, without a separate native filesystem lookup. + pub(crate) fn metadata(&self) -> io::Result { + match &self.0 { + FileReader::Local(file) => file.metadata().map(native_metadata), + FileReader::Remote { metadata, .. } => Ok(Metadata { + kind: metadata.kind, + size: metadata.size, + mode: metadata.mode, + modified: metadata.modified, + }), + } + } +} +impl Read for File { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + match &mut self.0 { + FileReader::Local(file) => file.read(buffer), + FileReader::Remote { + host, + path, + offset, + version, + eof, + .. + } => { + if buffer.is_empty() || *eof { + return Ok(0); + } + let result: methods::ReadFileResult = rpc( + host, + methods::FS_READ_FILE, + json!({"path": path, "offset": offset, "length": buffer.len().min(1024 * 1024)}), + )?; + let observed = methods::FileVersion { + size: result.metadata.size, + modified_ms: result.metadata.modified_ms, + }; + if version.as_ref().is_some_and(|first| *first != observed) { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "Remote file changed while being read", + )); + } + let bytes = methods::decode_bytes(&result.data).map_err(io::Error::other)?; + let next_offset = offset.checked_add(bytes.len() as u64).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid remote read offset") + })?; + if bytes.len() > buffer.len().min(1024 * 1024) + || next_offset > observed.size + || result.eof != (next_offset == observed.size) + || (bytes.is_empty() && !result.eof) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid remote read size", + )); + } + *version = Some(observed); + *eof = result.eof; + buffer[..bytes.len()].copy_from_slice(&bytes); + *offset = next_offset; + Ok(bytes.len()) + } + } + } +} + +/// Reads file bytes through the selected host adapter. +pub(crate) fn read(path_value: impl AsRef) -> io::Result> { + let path_value = path_value.as_ref(); + if !workspace::is_remote() { + return std::fs::read(path_value); + } + if metadata(path_value)?.len() > MAX_READ_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Remote file exceeds the 32 MiB read limit", + )); + } + let mut bytes = Vec::new(); + // The second bound also covers growth after the metadata request. + File::open(path_value)? + .take(MAX_READ_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_READ_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Remote file exceeds the 32 MiB read limit", + )); + } + Ok(bytes) +} +/// Reads UTF-8 text through the selected host adapter. +pub(crate) fn read_to_string(path_value: impl AsRef) -> io::Result { + String::from_utf8(read(path_value)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} +/// Atomically replaces remote content; local behavior retains standard filesystem semantics. +pub(crate) fn write(path_value: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + match workspace::current() { + Some(host) => { + check_write_size(bytes.as_ref().len() as u64)?; + rpc::( + &host, + methods::FS_WRITE_FILE, + json!({"path": path(&host, path_value.as_ref())?, + "data": methods::encode_bytes(bytes.as_ref()), "overwrite": true}), + ) + .map(|_| ()) + } + None => std::fs::write(path_value, bytes), + } +} +/// Writes and synchronizes a session record before publishing its manifest. +pub(crate) fn write_synced(path_value: &Path, bytes: &[u8]) -> io::Result<()> { + if workspace::is_remote() { + return write(path_value, bytes); + } + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path_value)?; + file.write_all(bytes)?; + file.sync_all() +} +/// Appends ignore rules, rejecting remote changes since the read used for this append. +pub(crate) fn append(path_value: &Path, bytes: &[u8]) -> io::Result<()> { + match workspace::current() { + Some(host) => { + let remote = path(&host, path_value)?; + let previous: Option = + match rpc(&host, methods::FS_STAT, json!({"path": remote})) { + Ok(value) => Some(value), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let mut content = if let Some(previous) = &previous { + check_write_size(previous.size.saturating_add(bytes.len() as u64))?; + read(path_value)? + } else { + Vec::new() + }; + content.extend_from_slice(bytes); + check_write_size(content.len() as u64)?; + rpc::(&host, methods::FS_WRITE_FILE, json!({"path": remote, "data": methods::encode_bytes(&content), + "expected": previous.map(|value| methods::FileVersion { size: value.size, modified_ms: value.modified_ms })})).map(|_| ()) + } + None => { + use std::io::Write; + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path_value)? + .write_all(bytes) + } + } +} + +fn check_write_size(length: u64) -> io::Result<()> { + if length > methods::MAX_TRANSFER_BYTES { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Remote file exceeds the 8 MiB atomic write limit", + )) + } else { + Ok(()) + } +} + +/// Creates one directory with optional permissions applied at creation. +pub(crate) fn create_dir_mode( + path_value: &Path, + recursive: bool, + mode: Option, +) -> io::Result<()> { + match workspace::current() { + Some(host) => rpc::( + &host, + methods::FS_CREATE_DIR, + json!({"path": path(&host, path_value)?, "recursive": recursive, "mode": mode}), + ) + .map(|_| ()), + None => { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(recursive); + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::DirBuilderExt; + builder.mode(mode); + } + builder.create(path_value) + } + } +} +/// Creates a directory without recursively creating parents. +pub(crate) fn create_dir(path_value: impl AsRef) -> io::Result<()> { + create_dir_mode(path_value.as_ref(), false, None) +} +/// Creates a directory and any missing parents. +pub(crate) fn create_dir_all(path_value: impl AsRef) -> io::Result<()> { + create_dir_mode(path_value.as_ref(), true, None) +} +/// Removes a disposable directory tree from its owning host. +pub(crate) fn remove_dir_all(path_value: impl AsRef) -> io::Result<()> { + match workspace::current() { + Some(host) => rpc::( + &host, + methods::FS_REMOVE, + json!({"path": path(&host, path_value.as_ref())?, "recursive": true}), + ) + .map(|_| ()), + None => std::fs::remove_dir_all(path_value), + } +} + +/// Deletes owned scratch state even after the user cancels its operation. +pub(crate) fn cleanup_directory(path_value: &Path) { + let result = if let Some(host) = workspace::current() { + path(&host, path_value).and_then(|path| { + host.connection + .session + .request_value( + methods::FS_REMOVE, + json!({"path": path, "recursive": true}), + Duration::from_secs(2), + ) + .map(|_| ()) + .map_err(|error| { + let kind = match &error { + ClientError::Remote(remote) if remote.code == ErrorCode::NotFound => { + io::ErrorKind::NotFound + } + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, error) + }) + }) + } else { + std::fs::remove_dir_all(path_value) + }; + if let Err(error) = result { + if error.kind() != io::ErrorKind::NotFound { + // Scratch names can identify user paths; log only the error category. + eprintln!( + "Could not clean workspace scratch directory: {:?}", + error.kind() + ); + } + } +} +/// Renames a file on its owning host, replacing an existing target. +pub(crate) fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + match workspace::current() { + Some(host) => rpc::(&host, methods::FS_RENAME, json!({"from": path(&host, from.as_ref())?, "to": path(&host, to.as_ref())?, "overwrite": true})).map(|_| ()), + None => std::fs::rename(from, to), + } +} +/// Copies a file on the host without transferring its content through the client. +pub(crate) fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { + match workspace::current() { + Some(host) => { + rpc::( + &host, + methods::FS_COPY, + json!({"from": path(&host, from.as_ref())?, "to": path(&host, to.as_ref())?, "overwrite": true}), + )?; + Ok(metadata(to)?.len()) + } + None => std::fs::copy(from, to), + } +} + +/// Directory entry preserving the client's workspace path identity. +pub(crate) struct DirEntry { + path: PathBuf, + kind: methods::FileKind, +} +impl DirEntry { + /// Name of this entry, without its parent path. + pub(crate) fn file_name(&self) -> std::ffi::OsString { + self.path.file_name().unwrap_or_default().to_owned() + } + /// Entry path in native workspace identity. + pub(crate) fn path(&self) -> PathBuf { + self.path.clone() + } + /// File type, without following symbolic links. + pub(crate) fn file_type(&self) -> io::Result { + Ok(FileType(self.kind)) + } +} +/// Lists a directory through the selected workspace adapter. +pub(crate) fn read_dir( + path_value: impl AsRef, +) -> io::Result>> { + let path_value = path_value.as_ref(); + match workspace::current() { + Some(host) => { + let result: methods::ReadDirResult = rpc( + &host, + methods::FS_READ_DIR, + json!({"path": path(&host, path_value)?}), + )?; + if result.skipped_non_utf8 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Remote directory has unsupported non-UTF-8 names", + )); + } + Ok(result + .entries + .into_iter() + .map(|entry| { + Ok(DirEntry { + path: path_value.join(entry.name), + kind: entry.metadata.kind, + }) + }) + .collect::>() + .into_iter()) + } + None => Ok(std::fs::read_dir(path_value)? + .map(|entry| { + let entry = entry?; + let metadata = native_metadata(std::fs::symlink_metadata(entry.path())?); + Ok(DirEntry { + path: entry.path(), + kind: metadata.kind, + }) + }) + .collect::>() + .into_iter()), + } +} + +/// Allocates a private host scratch directory; its caller owns deletion. +pub(crate) fn remote_temp_directory(prefix: &str) -> io::Result> { + workspace::current() + .map(|host| { + rpc::( + &host, + methods::FS_TEMP_DIRECTORY, + json!({"prefix": prefix}), + ) + .map(|value| PathBuf::from(value.path)) + }) + .transpose() +} diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index c33c2d39df..e8427312f0 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -108,6 +108,11 @@ fn execute(request: &str) -> CoreResponse { if let Err(error) = crate::protocol::cancellation::check() { return CoreResponse::failure(id, error); } + let _workspace = + match crate::remote::workspace::Scope::begin(parsed.remote_workspace, &parsed.command) { + Ok(scope) => scope, + Err(error) => return CoreResponse::failure(id, error), + }; let _git_policy = match crate::git::execution_policy::Scope::begin(parsed.git_execution) { Ok(scope) => scope, Err(error) => return CoreResponse::failure(id, error), @@ -2402,9 +2407,10 @@ fn execute(request: &str) -> CoreResponse { ), } } - CoreCommand::GitExecutable => { - CoreResponse::success(id, git::configuration::locate_executable()) - } + CoreCommand::GitExecutable => match git::configuration::locate_executable() { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, CoreCommand::RemoteProbe => { match serde_json::from_value::(parsed.payload) { Ok(request) => match crate::remote::probe(request) { @@ -2484,6 +2490,63 @@ fn execute(request: &str) -> CoreResponse { ), } } + CoreCommand::RemoteCaptureProcess => { + match serde_json::from_value(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host capture request") + .with_details(error.to_string()) + }) + .and_then(crate::remote::workspace_host::capture_process) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RemoteHostRequest + | CoreCommand::RemoteRunProcess + | CoreCommand::RemoteRunWatch + | CoreCommand::RemoteRunTerminal => { + let operation = match command { + CoreCommand::RemoteHostRequest => crate::remote::workspace_host::request, + CoreCommand::RemoteRunProcess => crate::remote::workspace_host::run_process, + CoreCommand::RemoteRunWatch => crate::remote::workspace_host::run_watch, + _ => crate::remote::workspace_host::run_terminal, + }; + match serde_json::from_value(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace host request") + .with_details(error.to_string()) + }) + .and_then(operation) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RemoteUploadFile => { + match serde_json::from_value(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid upload request") + .with_details(error.to_string()) + }) + .and_then(crate::remote::upload::upload) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RemoteHostInfo => { + match parsed + .payload + .get("sessionId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing host session")) + .and_then(crate::remote::upload::host_info) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::RemoteDisconnect => { match serde_json::from_value::( parsed.payload, diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 7a48f10033..84d72d09d1 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -4255,6 +4255,15 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { .as_bool() .unwrap() ); + // A host-read snapshot uses identical semantics without reopening + // a remote document from a UI-thread selection call. + let snapshot: Value = serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"snapshot", "command":"runConfig.selectJava", "payload":{ + "requirements": document, "candidates": candidates, "fallbackId": case["fallbackId"] + } + }).to_string())).unwrap(); + assert_eq!(snapshot["ok"], true, "{snapshot}"); + assert_eq!(snapshot["data"], response["data"]); } assert_eq!( fs::read_to_string(&requirements).unwrap(), @@ -4280,3 +4289,21 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { } } } + +#[test] +fn java_selection_rejects_ambiguous_or_invalid_host_snapshots() { + for payload in [ + serde_json::json!({"root":"/unused","requirements":{"version":1,"toolchains":{}},"candidates":[]}), + serde_json::json!({"requirements":{"version":999,"toolchains":{}},"candidates":[]}), + serde_json::json!({"requirements":{"toolchains":{}},"candidates":[]}), + ] { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"snapshot","command":"runConfig.selectJava","payload":payload + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{response}"); + } +} diff --git a/rust/lithe-remote-client/src/api.rs b/rust/lithe-remote-client/src/api.rs index 8c1f0ca5c6..b1e844130e 100644 --- a/rust/lithe-remote-client/src/api.rs +++ b/rust/lithe-remote-client/src/api.rs @@ -216,6 +216,7 @@ impl RemoteSession { &CreateDirParams { path: path.into(), recursive, + mode: None, }, ) } diff --git a/rust/lithe-remote-client/src/session.rs b/rust/lithe-remote-client/src/session.rs index 8c03b794f2..32541a876e 100644 --- a/rust/lithe-remote-client/src/session.rs +++ b/rust/lithe-remote-client/src/session.rs @@ -5,10 +5,10 @@ //! resource (`terminal.output` with `terminalId` 7 goes to the subscription //! for terminal 7) or, without one, to the session's general event channel. //! -//! Every channel is bounded and the reader blocks when one is full, which -//! stops reading the server's stream and so applies backpressure all the way -//! to the remote process. Consumers must therefore drain what they subscribe -//! to, and either drain the general channel or drop its receiver. +//! Every channel is bounded. A stalled resource loses its subscription instead +//! of blocking the shared reader: its owner observes disconnection and reclaims +//! the resource. General-channel overflow ends the connection, since unobserved +//! filesystem events cannot safely be discarded. //! //! A writer thread owns the outgoing stream. Requests queue their frames for //! it, so a peer that stops reading blocks only that thread, and every request @@ -180,20 +180,27 @@ struct Outgoing { } impl Outgoing { - /// Queues a frame, waiting until `deadline` for room. `Ok(false)` means - /// the queue is closed. - fn push(&self, id: u64, payload: Vec, deadline: Instant) -> Result { + /// Returns the still-owned frame when no queue space becomes available + /// during this poll. The caller can observe cancellation between polls. + fn push_until( + &self, + id: u64, + payload: Vec, + deadline: Instant, + ) -> Result>, ClientError> { let mut state = lock(&self.state); loop { if state.closed { - return Ok(false); + return Err(ClientError::Disconnected { + diagnostics: String::new(), + }); } if state.frames.is_empty() || state.bytes + payload.len() <= OUTGOING_QUEUE_BYTES { break; } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { - return Err(ClientError::Timeout); + return Ok(Some(payload)); } state = self .changed @@ -204,7 +211,7 @@ impl Outgoing { state.bytes += payload.len(); state.frames.push_back((id, payload)); self.changed.notify_all(); - Ok(true) + Ok(None) } /// Queues a frame only if there is room now. Used by the reader thread, @@ -272,6 +279,59 @@ pub struct RemoteSession { hello: Option, } +/// An owned request waiter. Dropping it withdraws unsent work and ignores late +/// replies, without closing the session or its other resources. +pub struct PendingRequest { + id: u64, + reply: Receiver>, + pending: Arc>, + outgoing: Arc, + deadline: Instant, + frame: Mutex>>, +} + +impl PendingRequest { + /// Waits for at most `interval`, returning `None` while the reply is pending. + /// The request's original deadline remains authoritative across polls. + pub fn poll(&self, interval: Duration) -> Result, ClientError> { + let poll_deadline = (Instant::now() + interval).min(self.deadline); + let mut frame = lock(&self.frame); + if let Some(payload) = frame.take() { + *frame = self.outgoing.push_until(self.id, payload, poll_deadline)?; + if frame.is_some() { + return if Instant::now() >= self.deadline { + Err(ClientError::Timeout) + } else { + Ok(None) + }; + } + } + drop(frame); + let remaining = self.deadline.saturating_duration_since(Instant::now()); + match self.reply.recv_timeout( + poll_deadline + .saturating_duration_since(Instant::now()) + .min(remaining), + ) { + Ok(result) => result.map(Some), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(ClientError::Disconnected { + diagnostics: String::new(), + }), + Err(mpsc::RecvTimeoutError::Timeout) if Instant::now() >= self.deadline => { + Err(ClientError::Timeout) + } + Err(mpsc::RecvTimeoutError::Timeout) => Ok(None), + } + } +} + +impl Drop for PendingRequest { + fn drop(&mut self) { + lock(&self.pending).waiting.remove(&self.id); + self.outgoing.withdraw(self.id); + } +} + impl RemoteSession { /// Runs `server_command` at the end of `chain` and completes the handshake. pub fn connect( @@ -463,6 +523,23 @@ impl RemoteSession { params: Value, timeout: Duration, ) -> Result { + let request = self.begin_request_value(method, params, timeout)?; + match request.poll(timeout) { + Ok(Some(value)) => Ok(value), + Err(ClientError::Disconnected { .. }) => Err(self.disconnected()), + Err(error) => Err(error), + Ok(None) => Err(ClientError::Timeout), + } + } + + /// Queues a request without blocking on its reply. Resource owners can drain + /// events and observe cancellation while waiting for creation or control. + pub fn begin_request_value( + &self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { let deadline = Instant::now() + timeout; let id = self.next_request.fetch_add(1, Ordering::Relaxed) + 1; let (reply_sender, reply) = mpsc::sync_channel(1); @@ -478,23 +555,18 @@ impl RemoteSession { method: method.into(), params, }; - if let Err(error) = self.send(id, &message, deadline) { + let payload = serde_json::to_vec(&message).map_err(|error| { lock(&self.pending).waiting.remove(&id); - return Err(error); - } - match reply.recv_timeout(deadline.saturating_duration_since(Instant::now())) { - Ok(Ok(value)) => Ok(value), - Ok(Err(ClientError::Disconnected { .. })) - | Err(mpsc::RecvTimeoutError::Disconnected) => Err(self.disconnected()), - Ok(Err(error)) => Err(error), - Err(mpsc::RecvTimeoutError::Timeout) => { - // Removing the waiter makes the reader drop a late response; - // a frame still queued is not sent at all. - lock(&self.pending).waiting.remove(&id); - self.outgoing.withdraw(id); - Err(ClientError::Timeout) - } - } + ClientError::Protocol(error.to_string()) + })?; + Ok(PendingRequest { + id, + reply, + pending: Arc::clone(&self.pending), + outgoing: Arc::clone(&self.outgoing), + deadline, + frame: Mutex::new(Some(payload)), + }) } /// Whether the stream has ended. @@ -509,15 +581,6 @@ impl RemoteSession { // Drop closes the stream and reaps the transport process. } - fn send(&self, id: u64, message: &Message, deadline: Instant) -> Result<(), ClientError> { - let payload = serde_json::to_vec(message) - .map_err(|error| ClientError::Protocol(error.to_string()))?; - match self.outgoing.push(id, payload, deadline)? { - true => Ok(()), - false => Err(self.disconnected()), - } - } - fn disconnected(&self) -> ClientError { ClientError::Disconnected { diagnostics: self.diagnostics(), @@ -607,7 +670,9 @@ fn read_loop( } } Ok(Message::Event { event, params, seq }) => { - route(&routes, &events, Event { event, params, seq }); + if !route(&routes, &events, Event { event, params, seq }) { + break; + } if let Some(seq) = seq { last_seq.store(seq, Ordering::Release); if seq >= acknowledged + ACK_INTERVAL @@ -653,23 +718,27 @@ fn write_loop(mut writer: Box, outgoing: &Outgoing, pending: & } } -fn route(routes: &Routes, events: &SyncSender, event: Event) { +fn route(routes: &Routes, events: &SyncSender, event: Event) -> bool { let key = event.resource().map(|(family, id)| (family.to_owned(), id)); - // The sender is cloned so that a blocking send does not hold the lock - // a consumer needs to subscribe or unsubscribe. + // Neither a resource nor the general consumer may block the sole response + // reader: a process owner needs that reader to acknowledge its kill request. let subscriber = key.as_ref().and_then(|key| lock(routes).get(key).cloned()); match subscriber { Some(subscriber) => { - if subscriber.send(event).is_err() { - if let Some(key) = key { - lock(routes).remove(&key); + match subscriber.try_send(event) { + Ok(()) => true, + Err(error) => { + if let Some(key) = key { + lock(routes).remove(&key); + } + // A live owner losing a frame must resynchronize. Dropping + // a receiver intentionally only ends that subscription. + matches!(error, mpsc::TrySendError::Disconnected(_)) } } } // A dropped general receiver means nobody consumes these events. - None => { - let _ = events.send(event); - } + None => !matches!(events.try_send(event), Err(mpsc::TrySendError::Full(_))), } } @@ -711,6 +780,73 @@ mod tests { use super::*; use serde_json::json; + fn waiter(outgoing: Arc, payload: Vec) -> (PendingRequest, SyncSender) { + let (sender, reply) = mpsc::sync_channel(1); + let pending = Arc::new(Mutex::new(Pending::default())); + lock(&pending).waiting.insert(1, sender.clone()); + ( + PendingRequest { + id: 1, + reply, + pending, + outgoing, + deadline: Instant::now() + Duration::from_secs(1), + frame: Mutex::new(Some(payload)), + }, + sender, + ) + } + + #[test] + fn dropping_an_unpolled_request_never_sends_it() { + let outgoing = Arc::new(Outgoing::default()); + let (request, sender) = waiter(outgoing.clone(), vec![1, 2]); + let pending = request.pending.clone(); + drop(request); + assert!(lock(&pending).waiting.is_empty()); + assert!(lock(&outgoing.state).frames.is_empty()); + assert!(sender.send(Ok(Value::Null)).is_err()); + } + + #[test] + fn full_writer_queue_remains_cancellable_between_polls() { + let outgoing = Arc::new(Outgoing::default()); + assert!(outgoing.try_push(7, vec![0; OUTGOING_QUEUE_BYTES])); + let (request, _) = waiter(outgoing.clone(), vec![1, 2]); + assert_eq!(request.poll(Duration::ZERO).unwrap(), None); + assert!(lock(&request.frame).is_some()); + drop(request); + assert_eq!(lock(&outgoing.state).frames.len(), 1); + } + + #[test] + fn dropping_a_queued_request_withdraws_only_its_frame() { + let outgoing = Arc::new(Outgoing::default()); + assert!(outgoing.try_push(7, vec![7])); + let (request, _) = waiter(outgoing.clone(), vec![1, 2]); + assert_eq!(request.poll(Duration::ZERO).unwrap(), None); + assert_eq!(lock(&outgoing.state).frames.len(), 2); + drop(request); + let state = lock(&outgoing.state); + assert_eq!(state.frames.len(), 1); + assert_eq!(state.frames[0].0, 7); + assert_eq!(state.bytes, 1); + } + + #[test] + fn polling_delivers_a_reply_without_resetting_the_deadline() { + let outgoing = Arc::new(Outgoing::default()); + let (request, sender) = waiter(outgoing, vec![1]); + let deadline = request.deadline; + assert_eq!(request.poll(Duration::ZERO).unwrap(), None); + sender.send(Ok(json!({"ok": true}))).unwrap(); + assert_eq!( + request.poll(Duration::ZERO).unwrap(), + Some(json!({"ok": true})) + ); + assert_eq!(request.deadline, deadline); + } + #[test] fn events_name_their_resource_by_family_and_id() { let event = Event { @@ -726,4 +862,63 @@ mod tests { }; assert_eq!(unrelated.resource(), None); } + + #[test] + fn full_resource_queue_requires_resynchronization_without_blocking_replies() { + let routes = Mutex::new(HashMap::new()); + let (full, stalled) = mpsc::sync_channel(1); + let (other, healthy) = mpsc::sync_channel(1); + let (general, _) = mpsc::sync_channel(1); + lock(&routes).insert(("process".into(), 7), full); + lock(&routes).insert(("process".into(), 8), other); + let event = |id| Event { + event: "process.output".into(), + params: json!({"processId": id, "data": ""}), + seq: None, + }; + assert!(route(&routes, &general, event(7))); + assert!(!route(&routes, &general, event(7))); + assert!(!lock(&routes).contains_key(&("process".into(), 7))); + assert!(route(&routes, &general, event(8))); + assert_eq!(healthy.try_recv().unwrap().resource(), Some(("process", 8))); + assert!(stalled.try_recv().is_ok()); + assert!(matches!( + stalled.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + } + + #[test] + fn general_overflow_requires_resynchronization_instead_of_losing_watch_changes() { + let routes = Mutex::new(HashMap::new()); + let (sender, receiver) = mpsc::sync_channel(1); + let event = || Event { + event: "watch.changed".into(), + params: json!({}), + seq: None, + }; + assert!(route(&routes, &sender, event())); + assert!(!route(&routes, &sender, event())); + drop(receiver); + assert!(route(&routes, &sender, event())); + } + + #[test] + fn dropped_resource_receiver_does_not_close_the_connection() { + let routes = Mutex::new(HashMap::new()); + let (sender, receiver) = mpsc::sync_channel(1); + let (general, _) = mpsc::sync_channel(1); + lock(&routes).insert(("process".into(), 7), sender); + drop(receiver); + assert!(route( + &routes, + &general, + Event { + event: "process.output".into(), + params: json!({"processId": 7, "data": ""}), + seq: None, + } + )); + assert!(!lock(&routes).contains_key(&("process".into(), 7))); + } } diff --git a/rust/lithe-remote-protocol/src/methods.rs b/rust/lithe-remote-protocol/src/methods.rs index 170a131484..863ccd34a7 100644 --- a/rust/lithe-remote-protocol/src/methods.rs +++ b/rust/lithe-remote-protocol/src/methods.rs @@ -30,9 +30,48 @@ pub const FS_CREATE_DIR: &str = "fs.createDir"; pub const FS_REMOVE: &str = "fs.remove"; pub const FS_RENAME: &str = "fs.rename"; pub const FS_COPY: &str = "fs.copy"; +/// Moves an entry to the host account's recoverable system trash. +pub const FS_TRASH: &str = "fs.trash"; pub const FS_REAL_PATH: &str = "fs.realPath"; pub const FS_SET_PERMISSIONS: &str = "fs.setPermissions"; pub const FS_VOLUME_INFO: &str = "fs.volumeInfo"; +pub const FS_TEMP_DIRECTORY: &str = "fs.tempDirectory"; +/// Connection-owned staging for large files, with verified no-replace publication. +pub const FS_UPLOAD_START: &str = "fs.uploadStart"; +pub const FS_UPLOAD_CHUNK: &str = "fs.uploadChunk"; +pub const FS_UPLOAD_COMMIT: &str = "fs.uploadCommit"; +pub const FS_UPLOAD_ABORT: &str = "fs.uploadAbort"; + +/// Maximum decoded chunk, independent of the full file size. +pub const MAX_UPLOAD_CHUNK_BYTES: usize = 256 * 1024; +/// Maximum staged file, matching the expanded plugin importer budget. +pub const MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +/// A new upload's destination, expected bytes, integrity and final permissions. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UploadStartParams { + pub path: String, + pub size: u64, + pub sha256: String, + pub mode: u32, +} + +/// One strictly ordered chunk of an existing upload. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UploadChunkParams { + pub upload_id: u64, + pub offset: u64, + pub data: String, +} + +/// Identifies the upload being committed or aborted. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UploadParams { + pub upload_id: u64, +} pub const WALK_START: &str = "walk.start"; pub const WALK_CANCEL: &str = "walk.cancel"; @@ -228,6 +267,8 @@ pub enum WatchBackend { pub enum Feature { /// `fs.*` methods. Filesystem, + /// Bounded staged uploads with SHA-256 and atomic no-replace publication. + Upload, /// `walk.*` methods and events. Walk, /// `watch.*` methods and events. @@ -319,6 +360,9 @@ pub struct FileMetadata { /// Kind of the resolved target; `None` for a dangling link. #[serde(default, skip_serializing_if = "Option::is_none")] pub symlink_target_kind: Option, + /// Permissions of the resolved target, never the link's own mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symlink_target_mode: Option, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] @@ -391,6 +435,17 @@ pub struct CreateDirParams { pub path: String, #[serde(default)] pub recursive: bool, + /// POSIX permissions applied during creation, before any content is written. + #[serde(default)] + pub mode: Option, +} + +/// Creates a private disposable directory below the host's temporary root. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TempDirectoryParams { + /// Diagnostic name, restricted to ASCII letters, digits, `_` and `-`. + pub prefix: String, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/rust/lithe-remote-server/Cargo.toml b/rust/lithe-remote-server/Cargo.toml index 47236c27e3..99ae6c0165 100644 --- a/rust/lithe-remote-server/Cargo.toml +++ b/rust/lithe-remote-server/Cargo.toml @@ -14,6 +14,7 @@ lithe-remote-protocol = { path = "../lithe-remote-protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +trash = { version = "5.2.6", default-features = false, features = ["chrono"] } [dev-dependencies] lithe-remote-client = { path = "../lithe-remote-client" } diff --git a/rust/lithe-remote-server/src/connection.rs b/rust/lithe-remote-server/src/connection.rs index 78f37227bf..95831dddc8 100644 --- a/rust/lithe-remote-server/src/connection.rs +++ b/rust/lithe-remote-server/src/connection.rs @@ -17,6 +17,7 @@ use lithe_remote_protocol::methods::{ use lithe_remote_protocol::{ErrorCode, Message, RemoteError, PREAMBLE, PROTOCOL_VERSION}; use serde_json::{json, Value}; use std::io::{self, Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::sync::Arc; use std::thread; @@ -39,6 +40,7 @@ pub(crate) struct Context { pub(crate) session: Arc, /// Owner of connection-bound resources such as tunnels. pub(crate) connection: u64, + pub(crate) closed: Arc, } pub(crate) fn serve( @@ -57,6 +59,7 @@ pub(crate) fn serve( let context = Context { session: Arc::clone(session), connection: session.next_connection_id(), + closed: Arc::new(AtomicBool::new(false)), }; let mut input = io::BufReader::new(input); let mut handshaken = false; @@ -136,6 +139,7 @@ pub(crate) fn serve( } } + context.closed.store(true, Ordering::Release); session.link.detach(context.connection); session.release_connection_resources(context.connection); let _ = sender.send(Outgoing::Close); @@ -221,6 +225,9 @@ fn handle(context: &Context, method: &str, params: Value) -> Result { + session.uploads.handle(context, method, params) + } method if method.starts_with("fs.") => fs::handle(method, params), method if method.starts_with("walk.") => session.walks.handle(context, method, params), method if method.starts_with("watch.") => session.watches.handle(context, method, params), diff --git a/rust/lithe-remote-server/src/daemon.rs b/rust/lithe-remote-server/src/daemon.rs index 7a01de79a5..c448f42e91 100644 --- a/rust/lithe-remote-server/src/daemon.rs +++ b/rust/lithe-remote-server/src/daemon.rs @@ -15,6 +15,7 @@ use crate::connection::{self, Ended}; use crate::platform; use crate::session::Session; +use sha2::{Digest, Sha256}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::os::fd::AsRawFd; @@ -91,10 +92,10 @@ impl PersistentOptions { ) .join(".lithe-server"), }; - // The server version is part of the name: a client upgrade starts a - // new daemon instead of talking to an older one, which ends on its - // own once idle. - let name = format!("{}-{}", self.key, env!("CARGO_PKG_VERSION")); + // Preview builds can change protocol features without changing version. + // Hash both identities to keep maximal session keys within socket limits. + let identity = crate::identity()?; + let name = daemon_name(&self.key, &identity.sha256); let run = state.join("run"); private_directory(&run)?; let mut socket = run.join(format!("{name}.sock")); @@ -122,6 +123,14 @@ impl PersistentOptions { } } +fn daemon_name(key: &str, executable_sha256: &str) -> String { + let mut digest = Sha256::new(); + digest.update(key.as_bytes()); + digest.update([0]); + digest.update(executable_sha256.as_bytes()); + format!("{:x}", digest.finalize())[..32].to_owned() +} + /// Creates `path` with mode 0700, or verifies an existing one is a real /// directory owned by the user that nobody else can write to. fn private_directory(path: &Path) -> io::Result<()> { @@ -397,3 +406,22 @@ fn write_pid(mut lock: &File) -> io::Result<()> { writeln!(lock, "{}", std::process::id())?; lock.flush() } + +#[cfg(test)] +mod identity_tests { + use super::*; + + #[test] + fn same_version_binary_rebuilds_cannot_reuse_a_daemon() { + let key = "a".repeat(64); + let old = daemon_name(&key, &"1".repeat(64)); + assert_eq!(old.len(), 32); + assert_eq!(old, daemon_name(&key, &"1".repeat(64))); + assert_ne!(old, daemon_name(&key, &"2".repeat(64))); + assert_ne!(old, daemon_name("other-session", &"1".repeat(64))); + let fallback = PathBuf::from(SHORT_SOCKET_ROOT) + .join(format!("lithe-server-{}", u32::MAX)) + .join(format!("{old}.sock")); + assert!(fallback.as_os_str().len() <= MAX_SOCKET_PATH_BYTES); + } +} diff --git a/rust/lithe-remote-server/src/fs.rs b/rust/lithe-remote-server/src/fs.rs index 82e67d9499..d1228cd95d 100644 --- a/rust/lithe-remote-server/src/fs.rs +++ b/rust/lithe-remote-server/src/fs.rs @@ -32,9 +32,19 @@ pub(crate) fn handle(method: &str, params: Value) -> Result methods::FS_READ_FILE => to_value(read_file(parse(params)?)?), methods::FS_WRITE_FILE => to_value(write_file(parse(params)?)?), methods::FS_CREATE_DIR => create_dir(parse(params)?).map(|()| Value::Null), + methods::FS_TEMP_DIRECTORY => to_value(temp_directory(parse(params)?)?), methods::FS_REMOVE => remove(parse(params)?).map(|()| Value::Null), methods::FS_RENAME => rename(parse(params)?).map(|()| Value::Null), methods::FS_COPY => to_value(copy(parse(params)?)?), + methods::FS_TRASH => { + let params: PathParams = parse(params)?; + let path = host_path(¶ms.path)?; + trash::delete(path).map_err(|error| { + RemoteError::new(ErrorCode::Io, "could not move the item to the host trash") + .with_details(json!({"reason": error.to_string()})) + })?; + Ok(Value::Null) + } methods::FS_REAL_PATH => { let params: PathParams = parse(params)?; let resolved = fs::canonicalize(host_path(¶ms.path)?) @@ -119,6 +129,9 @@ fn describe(path: &Path, metadata: &fs::Metadata) -> FileMetadata { mode: metadata.mode() & 0o7777, symlink_target, symlink_target_kind, + symlink_target_mode: (kind == FileKind::Symlink) + .then(|| fs::metadata(path).ok().map(|target| target.mode() & 0o7777)) + .flatten(), } } @@ -306,14 +319,59 @@ fn create_temporary(path: &Path, replaced: Option<&fs::Metadata>) -> io::Result< fn create_dir(params: CreateDirParams) -> Result<(), RemoteError> { let path = host_path(¶ms.path)?; - let created = if params.recursive { - fs::create_dir_all(&path) - } else { - fs::create_dir(&path) - }; + let mut builder = fs::DirBuilder::new(); + builder.recursive(params.recursive); + if let Some(mode) = params.mode { + if mode > 0o7777 { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid directory mode", + )); + } + use std::os::unix::fs::DirBuilderExt; + builder.mode(mode); + } + let created = builder.create(&path); created.map_err(|error| io_error(error, ¶ms.path)) } +fn temp_directory(params: methods::TempDirectoryParams) -> Result { + if params.prefix.is_empty() + || params.prefix.len() > 64 + || !params + .prefix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid temporary directory prefix", + )); + } + for _ in 0..128 { + let sequence = TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "{}-{}-{sequence}", + params.prefix, + std::process::id() + )); + use std::os::unix::fs::DirBuilderExt; + match fs::DirBuilder::new().mode(0o700).create(&path) { + Ok(()) => { + return Ok(RealPathResult { + path: path.to_string_lossy().into_owned(), + }) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(io_error(error, &path.to_string_lossy())), + } + } + Err(RemoteError::new( + ErrorCode::Io, + "could not allocate a private temporary directory", + )) +} + fn remove(params: RemoveParams) -> Result<(), RemoteError> { let path = host_path(¶ms.path)?; let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(error, ¶ms.path))?; @@ -390,6 +448,32 @@ fn copy(params: CopyParams) -> Result { _ => {} } let mut result = CopyResult::default(); + if !params.overwrite { + // Publish a complete copy with the same no-replace primitive as rename. + // An existence check alone can race another client and truncate its file. + let parent = to + .parent() + .ok_or_else(|| RemoteError::new(ErrorCode::InvalidPath, "cannot copy onto the root"))?; + let staging = parent.join(format!( + ".lithe-copy-{}-{}", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .mode(0o700) + .create(&staging) + .map_err(|error| io_error(error, ¶ms.to))?; + let copied = staging.join("content"); + let outcome = copy_entry(&from, &copied, &source, &mut result) + .and_then(|()| platform::rename_no_replace(&copied, &to)); + // This directory is owned solely by this request, never a user target. + if let Err(error) = fs::remove_dir_all(&staging) { + eprintln!("remote copy staging cleanup failed: {:?}", error.kind()); + } + outcome.map_err(|error| io_error(error, ¶ms.to))?; + return Ok(result); + } copy_entry(&from, &to, &source, &mut result).map_err(|error| io_error(error, ¶ms.from))?; Ok(result) } @@ -516,6 +600,30 @@ pub(crate) fn io_error(error: io::Error, path: &str) -> RemoteError { mod tests { use super::*; + #[test] + fn link_metadata_reports_target_permissions_not_symlink_permissions() { + let directory = std::env::temp_dir().join(format!( + "lithe-remote-link-mode-{}-{}", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&directory).unwrap(); + let _cleanup = RemoveOnDrop(directory.clone()); + let target = directory.join("target"); + let link = directory.join("link"); + fs::write(&target, b"not executable").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let metadata = describe(&link, &fs::symlink_metadata(&link).unwrap()); + assert_eq!(metadata.symlink_target_kind, Some(FileKind::File)); + assert_eq!(metadata.symlink_target_mode, Some(0o600)); + fs::remove_file(&target).unwrap(); + assert_eq!( + describe(&link, &fs::symlink_metadata(&link).unwrap()).symlink_target_mode, + None + ); + } + // Other users must not be able to open the temporary file while it holds // the new content of an owner-only file; tightening the mode afterwards // would not revoke a descriptor they already opened. diff --git a/rust/lithe-remote-server/src/lib.rs b/rust/lithe-remote-server/src/lib.rs index 1223e28d7f..46a8a8134f 100644 --- a/rust/lithe-remote-server/src/lib.rs +++ b/rust/lithe-remote-server/src/lib.rs @@ -33,6 +33,7 @@ mod session; mod spawner; mod terminal; mod tunnel; +mod upload; mod walk; mod watch; diff --git a/rust/lithe-remote-server/src/platform/mod.rs b/rust/lithe-remote-server/src/platform/mod.rs index be4f42cc2d..7b07e51154 100644 --- a/rust/lithe-remote-server/src/platform/mod.rs +++ b/rust/lithe-remote-server/src/platform/mod.rs @@ -51,6 +51,7 @@ pub(crate) fn host_capabilities() -> HostCapabilities { let account = unix::current_account(); let mut features = vec![ Feature::Filesystem, + Feature::Upload, Feature::Walk, Feature::Watch, Feature::Process, diff --git a/rust/lithe-remote-server/src/session.rs b/rust/lithe-remote-server/src/session.rs index bf2c8a5ea6..88629d7624 100644 --- a/rust/lithe-remote-server/src/session.rs +++ b/rust/lithe-remote-server/src/session.rs @@ -46,6 +46,7 @@ fn start_reaper(session: std::sync::Weak) -> std::io::Result<()> { }; session.processes.reap(); session.terminals.reap(); + session.uploads.expire(); }) .map(|_| ()) } @@ -61,6 +62,7 @@ pub(crate) struct Session { pub(crate) watches: WatchTable, pub(crate) tunnels: TunnelTable, pub(crate) walks: WalkTable, + pub(crate) uploads: crate::upload::UploadTable, /// Requests running on worker threads, drained before the session ends. pub(crate) workers: InFlight, /// Whether any client completed a handshake before, which makes the @@ -86,6 +88,7 @@ impl Session { watches: WatchTable::new(), tunnels: TunnelTable::new(), walks: WalkTable::new(), + uploads: crate::upload::UploadTable::default(), workers: InFlight::default(), had_client: AtomicBool::new(false), next_connection: AtomicU64::new(0), @@ -137,6 +140,7 @@ impl Session { /// local sockets are gone; walks end because their results would only /// fill the replay buffer. pub(crate) fn release_connection_resources(&self, connection: u64) { + self.uploads.release(connection); self.tunnels.close_owned_by(connection); self.walks.cancel_owned_by(connection); } @@ -154,5 +158,6 @@ impl Session { if unfinished > 0 { eprintln!("lithe-remote-server: {unfinished} requests did not finish"); } + self.uploads.clear(); } } diff --git a/rust/lithe-remote-server/src/upload.rs b/rust/lithe-remote-server/src/upload.rs new file mode 100644 index 0000000000..acaa2c64c5 --- /dev/null +++ b/rust/lithe-remote-server/src/upload.rs @@ -0,0 +1,203 @@ +//! Connection-owned, bounded uploads with verified atomic publication. +//! Staging files disappear on abort, disconnect, expiry, or failed commit. + +use crate::connection::Context; +use crate::fs::{host_path, io_error}; +use crate::{lock, parse, platform}; +use lithe_remote_protocol::methods::{self, UploadChunkParams, UploadParams, UploadStartParams}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const MAX_UPLOADS: usize = 4; +const LIFETIME: Duration = Duration::from_secs(5 * 60); +static SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Default)] +pub(crate) struct UploadTable(Mutex>); + +/// Owns one private staging inode until publication or cleanup. +struct Upload { + owner: u64, + file: File, + staging: PathBuf, + target: PathBuf, + size: u64, + received: u64, + expected: String, + mode: u32, + hash: Sha256, + expires: Instant, +} + +impl Drop for Upload { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.staging) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("remote upload cleanup failed: {:?}", error.kind()); + } + } + } +} + +impl UploadTable { + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + let mut uploads = lock(&self.0); + if context.closed.load(Ordering::Acquire) { + return Err(RemoteError::new( + ErrorCode::NotFound, + "upload connection closed", + )); + } + uploads.retain(|_, upload| upload.expires > Instant::now()); + if method == methods::FS_UPLOAD_START { + let params: UploadStartParams = parse(params)?; + if uploads.len() >= MAX_UPLOADS { + return Err(RemoteError::new(ErrorCode::Busy, "too many staged uploads")); + } + if params.size > methods::MAX_UPLOAD_BYTES + || params.mode > 0o777 + || params.sha256.len() != 64 + || !params.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid upload size, digest or mode", + )); + } + let target = host_path(¶ms.path)?; + let parent = target.parent().ok_or_else(|| { + RemoteError::new(ErrorCode::InvalidPath, "cannot upload to the root") + })?; + let id = SEQUENCE.fetch_add(1, Ordering::Relaxed); + let staging = parent.join(format!(".lithe-upload-{}-{id}", std::process::id())); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staging) + .map_err(|error| io_error(error, ¶ms.path))?; + uploads.insert( + id, + Upload { + owner: context.connection, + file, + staging, + target, + size: params.size, + received: 0, + expected: params.sha256.to_ascii_lowercase(), + mode: params.mode, + hash: Sha256::new(), + expires: Instant::now() + LIFETIME, + }, + ); + return Ok(json!({"uploadId": id})); + } + let id = params + .get("uploadId") + .and_then(Value::as_u64) + .ok_or_else(|| RemoteError::new(ErrorCode::InvalidRequest, "missing uploadId"))?; + if uploads + .get(&id) + .is_none_or(|upload| upload.owner != context.connection) + { + return Err(RemoteError::new( + ErrorCode::NotFound, + "staged upload not found", + )); + } + match method { + methods::FS_UPLOAD_CHUNK => { + let params: UploadChunkParams = parse(params)?; + let upload = uploads.get_mut(&id).unwrap(); + if params.data.len() > methods::MAX_UPLOAD_CHUNK_BYTES.div_ceil(3) * 4 { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "upload chunk too large", + )); + } + let bytes = methods::decode_bytes(¶ms.data).map_err(|_| { + RemoteError::new(ErrorCode::InvalidRequest, "invalid upload chunk") + })?; + if bytes.is_empty() || bytes.len() > methods::MAX_UPLOAD_CHUNK_BYTES { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "upload chunk size is invalid", + )); + } + if params.offset != upload.received + || upload.received + bytes.len() as u64 > upload.size + { + return Err(RemoteError::new( + ErrorCode::Conflict, + "upload offset or size mismatch", + )); + } + if let Err(error) = upload.file.write_all(&bytes) { + uploads.remove(&id); + return Err(io_error(error, "staged upload")); + } + upload.hash.update(&bytes); + upload.received += bytes.len() as u64; + Ok(json!({"offset": upload.received})) + } + methods::FS_UPLOAD_ABORT => { + let _: UploadParams = parse(params)?; + uploads.remove(&id); + Ok(Value::Null) + } + methods::FS_UPLOAD_COMMIT => { + let _: UploadParams = parse(params)?; + let upload = uploads.remove(&id).unwrap(); + let digest = upload + .hash + .clone() + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + if upload.received != upload.size || digest != upload.expected { + return Err(RemoteError::new( + ErrorCode::Conflict, + "upload integrity check failed", + )); + } + upload + .file + .set_permissions(fs::Permissions::from_mode(upload.mode)) + .and_then(|()| upload.file.sync_all()) + .and_then(|()| platform::rename_no_replace(&upload.staging, &upload.target)) + .map_err(|error| io_error(error, "staged upload"))?; + Ok(json!({"size": upload.size, "sha256": digest})) + } + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + "unknown upload operation", + )), + } + } + + pub(crate) fn release(&self, owner: u64) { + lock(&self.0).retain(|_, upload| upload.owner != owner); + } + pub(crate) fn expire(&self) { + lock(&self.0).retain(|_, upload| upload.expires > Instant::now()); + } + pub(crate) fn clear(&self) { + lock(&self.0).clear(); + } +} diff --git a/rust/lithe-remote-server/tests/server.rs b/rust/lithe-remote-server/tests/server.rs index a575632166..f81ad1f400 100644 --- a/rust/lithe-remote-server/tests/server.rs +++ b/rust/lithe-remote-server/tests/server.rs @@ -14,6 +14,7 @@ use lithe_remote_protocol::methods::{ }; use lithe_remote_protocol::{ErrorCode, Message}; use serde_json::json; +use sha2::{Digest, Sha256}; use std::io::BufReader; use std::process::{Command, Stdio}; use support::{connect, connect_command, options, TempDir, SERVER, STEP_TIMEOUT}; @@ -25,6 +26,221 @@ fn code(result: Result) -> ErrorCode { } } +#[test] +fn staged_upload_checks_order_digest_and_atomic_no_replace_publication() { + let root = TempDir::new("staged-upload"); + let (session, _events) = connect(); + let request = |method: &str, params| session.request_value(method, params, STEP_TIMEOUT); + let content = vec![b'p'; methods::MAX_UPLOAD_CHUNK_BYTES + 7]; + let digest = format!("{:x}", Sha256::digest(&content)); + let start = |name: &str, digest: &str| { + request(methods::FS_UPLOAD_START, + json!({"path": root.join(name), "size": content.len(), "sha256": digest, "mode": 0o755})).unwrap()["uploadId"].as_u64().unwrap() + }; + let id = start("package", &digest); + assert_eq!( + code(request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": 0, + "data": encode_bytes(&vec![b'x'; methods::MAX_UPLOAD_CHUNK_BYTES + 1])}) + )), + ErrorCode::InvalidRequest + ); + assert_eq!( + code(request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": 1, "data": encode_bytes(b"x")}) + )), + ErrorCode::Conflict + ); + for (index, chunk) in content.chunks(methods::MAX_UPLOAD_CHUNK_BYTES).enumerate() { + let offset = index * methods::MAX_UPLOAD_CHUNK_BYTES; + let received = request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": offset, "data": encode_bytes(chunk)}), + ) + .unwrap(); + assert_eq!(received["offset"], offset + chunk.len()); + assert!( + !std::path::Path::new(&root.join("package")).exists(), + "uncommitted bytes stay private" + ); + } + let result = request(methods::FS_UPLOAD_COMMIT, json!({"uploadId": id})).unwrap(); + assert_eq!(result["sha256"], digest); + assert_eq!(std::fs::read(root.join("package")).unwrap(), content); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(root.join("package")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + let bad = start("bad-digest", &"0".repeat(64)); + for (index, chunk) in content.chunks(methods::MAX_UPLOAD_CHUNK_BYTES).enumerate() { + request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": bad, "offset": index * methods::MAX_UPLOAD_CHUNK_BYTES, + "data": encode_bytes(chunk)}), + ) + .unwrap(); + } + assert_eq!( + code(request(methods::FS_UPLOAD_COMMIT, json!({"uploadId": bad}))), + ErrorCode::Conflict + ); + assert!(!std::path::Path::new(&root.join("bad-digest")).exists()); + let collision = start("package", &digest); + for (index, chunk) in content.chunks(methods::MAX_UPLOAD_CHUNK_BYTES).enumerate() { + request(methods::FS_UPLOAD_CHUNK, json!({"uploadId": collision, "offset": index * methods::MAX_UPLOAD_CHUNK_BYTES, "data": encode_bytes(chunk)})).unwrap(); + } + assert_eq!( + code(request( + methods::FS_UPLOAD_COMMIT, + json!({"uploadId": collision}) + )), + ErrorCode::AlreadyExists + ); + let aborted = start("aborted", &digest); + request(methods::FS_UPLOAD_ABORT, json!({"uploadId": aborted})).unwrap(); + assert_eq!( + std::fs::read_dir(root.path()).unwrap().count(), + 1, + "all staging inodes are removed" + ); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn staged_upload_disconnect_drops_unpublished_files_and_connection_ownership() { + let root = TempDir::new("upload-disconnect"); + let daemon = support::Daemon::new("upload-disconnect"); + let (session, _events) = daemon.connect(None); + let id = session.request_value(methods::FS_UPLOAD_START, + json!({"path": root.join("package"), "size": 1, "sha256": "0".repeat(64), "mode": 0o600}), STEP_TIMEOUT).unwrap()["uploadId"].as_u64().unwrap(); + drop(session); + let (replacement, _) = daemon.connect(None); + assert_eq!( + code(replacement.request_value( + methods::FS_UPLOAD_COMMIT, + json!({"uploadId": id}), + STEP_TIMEOUT + )), + ErrorCode::NotFound + ); + replacement.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped(), "upload server did not stop"); + assert!(std::fs::read_dir(root.path()).unwrap().next().is_none()); +} + +#[test] +fn staged_upload_limit_and_shutdown_release_private_files() { + let root = TempDir::new("upload-cleanup"); + let daemon = support::Daemon::new("upload-cleanup"); + let (session, _events) = daemon.connect(None); + for index in 0..4 { + session.request_value(methods::FS_UPLOAD_START, + json!({"path": root.join(&format!("file-{index}")), "size": 1, "sha256": "0".repeat(64), "mode": 0o600}), STEP_TIMEOUT).unwrap(); + } + assert_eq!(code(session.request_value(methods::FS_UPLOAD_START, + json!({"path": root.join("overflow"), "size": 1, "sha256": "0".repeat(64), "mode": 0o600}), STEP_TIMEOUT)), ErrorCode::Busy); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped(), "upload server did not stop"); + assert!(std::fs::read_dir(root.path()).unwrap().next().is_none()); +} + +#[test] +fn tree_mutations_copy_publish_without_replacing_and_preserve_symbolic_links() { + let root = TempDir::new("tree-mutations"); + let (session, _events) = connect(); + let request = |method: &str, params| session.request_value(method, params, STEP_TIMEOUT); + request( + methods::FS_CREATE_DIR, + json!({"path": root.join("source"), "recursive": false}), + ) + .unwrap(); + std::fs::write(root.join("source/file with spaces"), "host content").unwrap(); + std::os::unix::fs::symlink("file with spaces", root.join("source/link")).unwrap(); + let copied = request( + methods::FS_COPY, + json!({"from": root.join("source"), "to": root.join("copy"), "recursive": true}), + ) + .unwrap(); + assert_eq!(copied["skippedSpecialFiles"], 0); + assert_eq!( + std::fs::read_to_string(root.join("copy/link")).unwrap(), + "host content" + ); + assert!(std::fs::symlink_metadata(root.join("copy/link")) + .unwrap() + .is_symlink()); + std::fs::write(root.join("copy/file with spaces"), "destination edits").unwrap(); + assert_eq!( + code(request( + methods::FS_COPY, + json!({"from": root.join("source"), "to": root.join("copy"), "recursive": true}) + )), + ErrorCode::AlreadyExists + ); + assert_eq!( + std::fs::read_to_string(root.join("copy/file with spaces")).unwrap(), + "destination edits" + ); + request( + methods::FS_RENAME, + json!({"from": root.join("copy"), "to": root.join("renamed")}), + ) + .unwrap(); + request( + methods::FS_REMOVE, + json!({"path": root.join("renamed"), "recursive": true}), + ) + .unwrap(); + assert!(!std::path::Path::new(&root.join("renamed")).exists()); + assert_eq!( + std::fs::read_dir(root.path()).unwrap().count(), + 1, + "no staging directory remains" + ); + session.shutdown(STEP_TIMEOUT); +} + +#[cfg(target_os = "linux")] +#[test] +fn trash_moves_content_and_recovery_metadata_to_the_remote_account_data_directory() { + let root = TempDir::new("trash"); + let (session, _events) = support::connect_with_environment(&[("XDG_DATA_HOME", root.str())]); + std::fs::write(root.join("recover me.txt"), "keep these bytes").unwrap(); + session + .request_value( + methods::FS_TRASH, + json!({"path": root.join("recover me.txt")}), + STEP_TIMEOUT, + ) + .unwrap(); + assert!(!std::path::Path::new(&root.join("recover me.txt")).exists()); + let entries = std::fs::read_dir(root.join("Trash/files")) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + std::fs::read_to_string(entries[0].path()).unwrap(), + "keep these bytes" + ); + let metadata = std::fs::read_dir(root.join("Trash/info")) + .unwrap() + .next() + .unwrap() + .unwrap(); + let metadata = std::fs::read_to_string(metadata.path()).unwrap(); + assert!(metadata.contains("[Trash Info]")); + assert!(metadata.contains("recover%20me.txt")); + session.shutdown(STEP_TIMEOUT); +} + #[test] fn handshake_reports_the_host_as_capabilities() { let (session, _events) = connect(); diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 5f0747da99..e5a1f28fc8 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -399,6 +399,11 @@ async function main() { // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. // Includes remote-server-artifacts: packaging rebuilds them from this worktree's sources. // Includes remote-connection-state: SSH settings, keychain secrets and placeholder roots are user-owned. + // Includes remote-workspace-session-state: host Git scratch and connection + // discovery/index snapshots, copy staging, account Trash and watch subscriptions + // have execution/user ownership, never a build stamp or copy stage. + // Build/provider state, approved plugin-run snapshots, upload staging and + // native marketplace extraction also stay on that rejection route. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging // and active/previous approvals are runtime-owned. Reject before resolving worktrees // or calling validatorArguments; a content digest is neither a build stamp nor consent. diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 0bdabe9e56..d694636d87 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,29 @@ function reuse(extraArguments = []) { } try { + await test("remote host resources and session snapshots cannot cross worktrees", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + for (const id of ["remote-server-artifacts", "remote-connection-state", "remote-workspace-session-state"]) { + const resource = registry.excludedResources.find(item => item.id === id); + assert.equal(resource.reusable, false); + assert.ok(!listed.stdout.includes(id)); + const refused = reuse(["--resource", id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), new RegExp(`${id} is isolated.*cannot be reused`)); + } + const state = registry.excludedResources.find(item => item.id === "remote-connection-state"); + assert.ok(state.locations.some(location => location.includes("session-and-binary-hash"))); + const snapshots = registry.excludedResources.find(item => item.id === "remote-workspace-session-state"); + assert.ok(snapshots.locations.some(location => location.includes("lithe-git-"))); + assert.ok(snapshots.locations.some(location => location.includes(""))); + assert.ok(snapshots.locations.some(location => location.includes(".lithe-copy-"))); + assert.ok(snapshots.locations.some(location => location.includes("account-or-volume-trash"))); + for (const component of [".lithe-upload-", "plugin-runs", "state/plugins", "lithe-plugin-download-"]) { + assert.ok(snapshots.locations.some(location => location.includes(component)), component); + } + }); await test("shared translations are regenerated locally rather than reused", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "shared-i18n"); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 89ea9c4c36..a423ed47f0 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -377,10 +377,31 @@ "/lithe.remote.ssh-connections.v1", "//remote.ssh..secret", "/Lithe/RemoteWorkspaces///", - "/~/.lithe-server/bin/-" + "/~/.lithe-server/bin/-", + "/~/.lithe-server/run/.{sock,lock}", + "/~/.lithe-server/log/.log" ], "identity": "User-owned SSH connection settings, remembered secrets, empty placeholder roots of opened remote workspaces, and servers installed on remote hosts; no build identity.", - "reason": "Connections and secrets belong to the user's profile and the keychain. Placeholder roots are empty directories that key per-project state for remote folders and hold no files. Remote installs live on the hosts and are verified by SHA-256 before reuse. None has a copy stage between worktrees, and none is written into the app bundle or installation directory." + "reason": "Connections and secrets belong to the user's profile and the keychain. Placeholder roots are empty directories that key per-project state for remote folders and hold no files. Remote installs live on the hosts and are verified by SHA-256 before reuse. Daemon socket, lock and log identity includes the session key and complete executable SHA-256, so rebuilding an unchanged version cannot reconnect to older code. None has a copy stage between worktrees, and none is written into the app bundle or installation directory." + }, + { + "id": "remote-workspace-session-state", + "reusable": false, + "locations": [ + "//{lithe-git-snapshot,lithe-line-history}-", + "//lithe-commit-", + "//.lithe-copy--", + "//{files,info}", + "//.lithe-upload--", + "/~/.lithe-server/plugin-runs//package", + "/~/.lithe-server/state/plugins//{build-systems,vcs-providers,language-servers}//workspaces/", + "/lithe-plugin-download-", + "//remote-watch-subscriptions", + "//remote-runtime-and-shell-snapshots", + "//remote-git-executable-and-search-index" + ], + "identity": "Mutable per-operation Git/build scratch, private plugin snapshots, connection-owned upload staging, provider workspace state and per-connection runtime, shell, executable and search snapshots. They have no reusable version/platform/architecture/toolchain build identity.", + "reason": "The existing workspace engines own these records while generic host filesystem/process/PTY adapters perform I/O. Git/build scratch lives on its host and is never mirrored into the local placeholder. Upload staging expires or is removed on abort/disconnect; completed plugin runs are removed after process destruction, but crash/disconnect recovery of published runs remains TODO. Provider state persists on the host. Native marketplace extraction is deleted after transfer or failure. Package trust/hash/architecture checks authorize one execution and do not establish reusable build identity. Host discovery is memory-only and invalidated on explicit refresh or connection replacement. All reuse stages reject these entries before validation or copying. Runtime writes do not reach installed bundles, code signatures or Sparkle delta baselines." }, { "id": "shared-i18n", diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/shared/contracts/remote-workspace-protocol-v1.md index 898dfd2b53..49d9637c50 100644 --- a/shared/contracts/remote-workspace-protocol-v1.md +++ b/shared/contracts/remote-workspace-protocol-v1.md @@ -83,13 +83,18 @@ subscribe before creating the resource. | `fs.readDir` | `path` | `entries` sorted by name byte order, `skippedNonUtf8` | | `fs.readFile` | `path`, `offset`, `length` (≤ 8 MiB) | `data`, `eof`, `metadata` | | `fs.writeFile` | `path`, `data` (≤ 8 MiB), `expected?`, `overwrite`, `createParents` | metadata after the write | -| `fs.createDir` | `path`, `recursive` | `null` | +| `fs.createDir` | `path`, `recursive`, `mode?` (at most `0o7777`) | `null` | | `fs.remove` | `path`, `recursive` | `null`; links are removed, never followed | | `fs.rename` | `from`, `to`, `overwrite` | `null` | | `fs.copy` | `from`, `to`, `recursive`, `overwrite` | `skippedSpecialFiles`; links are copied as links | +| `fs.trash` | `path` | `null`; moves the entry to the host account's recoverable system trash | | `fs.realPath` | `path` | `path` with links resolved | | `fs.setPermissions` | `path`, `mode` (≤ `0o7777`) | `null` | | `fs.volumeInfo` | `path` | `caseSensitive` (`null` when unknown) | +| `fs.uploadStart` | `path`, `size`, `sha256`, `mode` | `uploadId` | +| `fs.uploadChunk` | `uploadId`, `offset`, `data` | next `offset` | +| `fs.uploadCommit` | `uploadId` | verified `size`, `sha256` | +| `fs.uploadAbort` | `uploadId` | `null` | | `walk.start` | `walkId`, `root`, `excludeNames`, `maxEntries?` | `null`; entries follow as events | | `walk.cancel` | `walkId` | `null` | | `watch.start` | `watchId`, `root`, `excludeNames` | `watchedDirectories` (per-directory backends) | @@ -114,6 +119,15 @@ directly, through a symbolic link in either direction or onto a hard link — and to copy a directory into itself after resolving links. Sockets, FIFOs and devices are never copied: at the top level the request fails with `UNSUPPORTED`, inside a tree they are skipped and counted. +With `overwrite: false`, a private sibling staging directory holds the copy +until an atomic no-replace rename publishes it. A concurrent destination +creation fails with `ALREADY_EXISTS` and leaves that destination unchanged. + +`fs.trash` uses the host's native trash implementation (Freedesktop Trash on +Linux and the system Trash on macOS). Recovery content and metadata belong to +the remote account, never to the client placeholder or installation. A host +without usable trash storage returns an error and retains the original entry; +it does not fall back to permanent deletion. `fs.writeFile` replaces the file atomically through a temporary sibling and writes through symbolic links. With `expected` (`size`, `modifiedMs`) it fails @@ -123,6 +137,19 @@ only replaced when `overwrite` is set. A write with neither `expected` nor the other fails with `ALREADY_EXISTS`. When a file is replaced, the temporary file never has broader permissions than the original. +Hosts advertising `features: ["upload", ...]` accept staged uploads without +increasing the frame limit. Upload IDs are server-assigned and owned by the +connection that created them, including in a persistent session. At most four +uploads are staged per server; each file is at most 2 GiB. Chunks contain +1–256 KiB of decoded bytes and must name the exact acknowledged offset. +`sha256` is a 64-character hexadecimal digest; `mode` permits only `0o000`–`0o777`. +Staging uses a private `0600` sibling file. Commit verifies size and digest, +syncs the file and atomically publishes without replacing an existing target. +Failed commits, aborts, disconnects, session shutdown and an absolute five-minute +expiry remove staging files. Clients serialize chunks and commit only after +the last acknowledgement. Interrupted clients may not know whether a completed +commit published; owners must remove their allocated run directory separately. + `process.exit` and `terminal.exit` report the leader. The server keeps owning its session until no member is left, so members that outlive the leader can still be killed and are cleaned up when the session ends; `server.state` diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index ad88313401..800a5f8c22 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3463,6 +3463,13 @@ the remote server running. Requests over it take `sessionId`: | `remote.readDir` | `{ sessionId, path }` | the remote protocol's `fs.readDir` result | | `remote.readFile` | `{ sessionId, path }` | `{ data (base64), size, modifiedMs }`; files over 16 MiB are `not_supported` | | `remote.writeFile` | `{ sessionId, path, data, expected? }` | `{ status: "saved", size, modifiedMs }` or `{ status: "conflict" }` | +| `remote.hostRequest` | `{ sessionId, method, params }` | the finite host method's result, including JSON `null` | +| `remote.hostInfo` | `{ sessionId }` | the original connection's handshake `host`, including OS, architecture, home and features | +| `remote.uploadFile` | `{ sessionId, sourcePath, path, sha256, mode }` | verified `{ size, sha256 }` | +| `remote.runWatch` | `{ sessionId, params: { root, excludeNames? } }` | waits until root removal or cancellation; relays `watch.started`, `watch.changed`, `watch.error` | +| `remote.runProcess` | `{ sessionId, params: { argv, cwd?, environment?, env?, pipeStdin? } }` | leader exit; relays `process.started`, `process.output`, `process.exit` | +| `remote.runTerminal` | `{ sessionId, params: { argv?, cwd?, env?, columns, rows } }` | leader exit; relays `terminal.started`, `terminal.output`, `terminal.exit` | +| `remote.captureProcess` | `{ sessionId, params, timeoutMilliseconds, maxOutputBytes }` | base64 `stdout`, `stderr`, nullable `exitCode` and `signal` | `remote.writeFile` replaces the file atomically on the host. With `expected` (`{ size, modifiedMs }` from the read) a changed file is a @@ -3471,6 +3478,33 @@ the remote server running. Requests over it take `sessionId`: fails with `connection_closed` and is forgotten, so the host reconnects; an unknown or closed `sessionId` is also `connection_closed`. +`remote.hostRequest` accepts finite filesystem, environment and port queries, +plus controls of existing processes and terminals. Creating a watch, process, +terminal, walk or tunnel through that command is `invalid_request`; creation +requires an owner that subscribes before sending the start request. Streamed +Core events use `{ type: "remote", event, params }`. Resource IDs are allocated +by Core and replace any caller-supplied ID. Startup output is bounded to 8 MiB. +Cancellation, invalid event data and disconnect release the subscription and +stop the host resource with a two-second cleanup deadline. + +Watch startup emits `watch.started` before buffered changes. The caller rescans +on startup, `overflow`, or watch error; `ROOT_REMOVED` ends the call. Watch paths +remain absolute host paths until the native adapter maps the owning workspace +back to placeholder URLs. A limit error preserves the partially working watch +and requests a rescan. Process capture requires a 1–30,000 ms deadline and +1–8 MiB output budget, rejects open stdin, and owns cleanup on overflow/timeout. + +`remote.uploadFile` reads a native absolute regular file and streams it to the +specified host using the optional `upload` feature. It verifies source size, +chunk acknowledgements and the commit receipt; failure attempts an uncancelled +abort with a two-second deadline. Each host call has a 30-second deadline, while +the host imposes the five-minute staging lifetime. This command does not approve +plugin code: the platform package adapter validates trust, version, manifest and +target architecture before invoking it. Target publication never overwrites. +Successful upload ownership transfers to the caller, which removes its run +snapshot after the process has ended. The native source and installation remain +read-only. See the staged upload limits in `remote-workspace-protocol-v1.md`. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 6f6aedd52f..8d6a0880e9 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9293,7 +9293,25 @@ "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", + "native.remoteTerminalAsyncStart": "The remote terminal must be started asynchronously.", + "native.remoteTerminalInvalidDirectory": "The terminal directory is outside the remote workspace.", + "native.remoteTerminalClosedBeforeStart": "The remote terminal closed before it started.", + "native.remoteTerminalInputBusy": "The remote terminal is busy. Wait before sending more input.", + "native.remoteGitMixedHosts": "A Git operation cannot span different workspace hosts.", + "native.remoteGitMixedLocal": "A Git operation cannot span local and remote workspaces.", + "native.runtimeDiscoveryFailed": "Runtime discovery failed", + "native.remoteRuntimeDiscoveryFailed": "Could not discover runtimes on the remote host. Check the connection and refresh.", + "native.runtimeDiscoveryRecovery": "Check the workspace connection and refresh the available runtimes.", + "native.remoteRuntimeToolMissing": "The required tool is not available on the remote host.", + "native.remoteRuntimeToolRecovery": "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes.", + "native.remoteShellDiscoveryFailed": "Could not discover shells on the remote host. Check the connection and try again.", + "remote.copyIncomplete": "Some special files could not be copied", + "remote.pendingDocumentsLimit": "Remote pending documents exceeded the startup limit. Wait for the language server to start.", + "remote.pluginUnapproved": "The remote plugin package is not part of the approved installation.", + "remote.pluginHostUnsupported": "This host cannot receive a compatible plugin package. Update the remote server.", + "remote.pluginApprovalRequired": "The remote plugin package changes approved commands or permissions. Install and approve that version first.", + "remote.pluginPlatformUnavailable": "Plugin {plugin} has no approved package for {platform}. Install a compatible plugin version." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index ef2f3740ad..e71051d100 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9293,7 +9293,25 @@ "native.openLocalFolder": "本地文件夹", "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", - "native.thisFolderIsEmpty": "此文件夹为空" + "native.thisFolderIsEmpty": "此文件夹为空", + "native.remoteTerminalAsyncStart": "远程终端需要异步启动。", + "native.remoteTerminalInvalidDirectory": "终端目录不在远程工作区内。", + "native.remoteTerminalClosedBeforeStart": "远程终端在启动完成前已关闭。", + "native.remoteTerminalInputBusy": "远程终端正忙,请稍后再发送输入。", + "native.remoteGitMixedHosts": "一次 Git 操作不能跨越不同的工作区主机。", + "native.remoteGitMixedLocal": "一次 Git 操作不能同时跨越本地和远程工作区。", + "native.runtimeDiscoveryFailed": "运行环境探测失败", + "native.remoteRuntimeDiscoveryFailed": "无法探测远程主机的运行环境,请检查连接后刷新。", + "native.runtimeDiscoveryRecovery": "请检查工作区连接并刷新可用运行环境。", + "native.remoteRuntimeToolMissing": "远程主机上没有可用的所需工具。", + "native.remoteRuntimeToolRecovery": "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。", + "native.remoteShellDiscoveryFailed": "无法探测远程主机上的 Shell。请检查连接后重试。", + "remote.copyIncomplete": "部分特殊文件未能复制", + "remote.pendingDocumentsLimit": "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。", + "remote.pluginUnapproved": "远程插件包不属于已批准的安装。", + "remote.pluginHostUnsupported": "此主机无法接收兼容的插件包,请更新远程服务。", + "remote.pluginApprovalRequired": "远程插件包更改了已批准的命令或权限,请先安装并批准该版本。", + "remote.pluginPlatformUnavailable": "插件 {plugin} 没有适用于 {platform} 的已批准包,请安装兼容版本。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index d42cb994ba..a3da860758 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -88232,6 +88232,211 @@ "key": "This folder is empty", "arguments": [] } + }, + { + "id": "native.remoteTerminalAsyncStart", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote terminal must be started asynchronously.", + "arguments": [] + } + }, + { + "id": "native.remoteTerminalInvalidDirectory", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The terminal directory is outside the remote workspace.", + "arguments": [] + } + }, + { + "id": "native.remoteTerminalClosedBeforeStart", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote terminal closed before it started.", + "arguments": [] + } + }, + { + "id": "native.remoteTerminalInputBusy", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote terminal is busy. Wait before sending more input.", + "arguments": [] + } + }, + { + "id": "native.remoteGitMixedHosts", + "description": "Remote workspace host isolation", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Git operation cannot span different workspace hosts.", + "arguments": [] + } + }, + { + "id": "native.remoteGitMixedLocal", + "description": "Remote workspace host isolation", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Git operation cannot span local and remote workspaces.", + "arguments": [] + } + }, + { + "id": "native.runtimeDiscoveryFailed", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime discovery failed", + "arguments": [] + } + }, + { + "id": "native.remoteRuntimeDiscoveryFailed", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not discover runtimes on the remote host. Check the connection and refresh.", + "arguments": [] + } + }, + { + "id": "native.runtimeDiscoveryRecovery", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check the workspace connection and refresh the available runtimes.", + "arguments": [] + } + }, + { + "id": "native.remoteRuntimeToolMissing", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "The required tool is not available on the remote host.", + "arguments": [] + } + }, + { + "id": "native.remoteRuntimeToolRecovery", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes.", + "arguments": [] + } + }, + { + "id": "native.remoteShellDiscoveryFailed", + "description": "Remote terminal shell discovery failure and recovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not discover shells on the remote host. Check the connection and try again.", + "arguments": [] + } + }, + { + "id": "remote.copyIncomplete", + "description": "A remote directory copy skipped sockets, devices or named pipes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some special files could not be copied", + "arguments": [] + } + }, + { + "id": "remote.pendingDocumentsLimit", + "description": "Bounded document snapshots while a remote backend is starting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote pending documents exceeded the startup limit. Wait for the language server to start.", + "arguments": [] + } + }, + { + "id": "remote.pluginUnapproved", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote plugin package is not part of the approved installation.", + "arguments": [] + } + }, + { + "id": "remote.pluginHostUnsupported", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This host cannot receive a compatible plugin package. Update the remote server.", + "arguments": [] + } + }, + { + "id": "remote.pluginApprovalRequired", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote plugin package changes approved commands or permissions. Install and approve that version first.", + "arguments": [] + } + }, + { + "id": "remote.pluginPlatformUnavailable", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin %@ has no approved package for %@. Install a compatible plugin version.", + "arguments": [ + "plugin", + "platform" + ] + }, + "parameters": { + "plugin": "string", + "platform": "string" + } } ] } diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index 64d144cec4..15980b973f 100644 --- a/shared/platform-feature-matrix/features/remote-development-workspace.json +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -9,6 +9,12 @@ "macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift", "macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift", "macos/Tests/LitheTests/RemoteConnectionsTests.swift", + "macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift", + "macos/Tests/LitheTests/RemoteTerminalTransportTests.swift", + "macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift", + "rust/lithe-core/src/remote/sessions_integration_tests.rs", "rust/lithe-core/src/remote", "rust/lithe-remote-client/src/launch.rs", "rust/lithe-remote-protocol", @@ -19,7 +25,14 @@ ".agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md", ".agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md", "macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift", - "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift" + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift", + "macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift", + "macos/Tests/LitheTests/RemoteBuildExecutionTests.swift", + "macos/Tests/LitheTests/RemotePluginDeploymentTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -32,5 +45,5 @@ "verificationStatus": "not-applicable" }, "owner": "Workspace", - "verification": "欢迎页点“远程开发”:新建一条 Linux SSH 连接(分别试 SSH Agent、私钥和密码,密码主机再试一次 2FA),测试连接后核对主机信息卡片,手动查询地区;点“打开”后文件树列出远端目录,打开、编辑并保存一个文件,在远端用其他方式修改同一文件后再次保存应提示冲突。远程 Mac、容器、Git、搜索、终端、构建和文件监听尚未接入远程工作区。Rust 后端由 scripts/verify-remote-workspace.sh 验证。 远程开发窗口与 Open 面板的尺寸随父窗口缩小(远程开发最小 640×420),在 800×650 窗口中确认主机卡片的地址行分两行显示、位置信息换行完整、架构标签与底部按钮不被裁掉,连续编辑字段尺寸不再变化。" + "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、完整插件快照的断线/崩溃回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。" } diff --git a/shared/platform-feature-matrix/features/remote-workspace.json b/shared/platform-feature-matrix/features/remote-workspace.json index 4160257691..cc310eadd7 100644 --- a/shared/platform-feature-matrix/features/remote-workspace.json +++ b/shared/platform-feature-matrix/features/remote-workspace.json @@ -5,10 +5,19 @@ "capability": "远程连接与远程路径工作区", "macos": { "evidence": [ - "macos/Sources/Lithe/Services" + "macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift", + "macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift", + "macos/Tests/LitheTests/RemoteConnectionsTests.swift", + "macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift", + "macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift", + "macos/Tests/LitheTests/RemoteBuildExecutionTests.swift", + "macos/Tests/LitheTests/RemotePluginDeploymentTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "windows": { "evidence": [ @@ -19,5 +28,5 @@ "verificationStatus": "pending" }, "owner": "Remote", - "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 需要补充产品入口或明确不支持。" + "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 从欢迎页远程开发入口连接 Linux SSH 主机,验证文件树、编辑保存冲突、断线通知与重连。macOS 路由与断线 fixture 已有自动测试,真实 SSH 流程仍待验收;完整远程开发进度见 remote-development-workspace。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 构建与插件接入进度同步至 remote-development-workspace;本行保持 partial/pending,不能以本机 fixture 替代真实 SSH 验收。" } From 2a3b8e671438ca57df1be45dc087d950314d5afb Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 19:46:55 +0800 Subject: [PATCH 2/5] fix(remote): continue #1507 common backend lifecycle and plugin validation Integrate PR #1515 onto the preview baseline, preserving plugin live activation and shared i18n changes. Reserve pending process, terminal and watch starts before dispatch; retain cleanup ownership across cancellation, worker saturation, disconnect and session shutdown. Add deterministic lifecycle and declarative remote launch regressions; isolate the empty connection registry test, fix Swift fixture shadowing and synchronize terminal resize with shell readiness. Update contracts, Agent Notes and partial/pending capability evidence. Java/JDT integration remains out of scope. --- .../2026-10-24-remote-workspace-routing.md | 40 +- docs/ci-builds.md | 24 + macos/Resources/en.lproj/Localizable.strings | 36 + .../zh-Hans.lproj/Localizable.strings | 36 + .../Core/Ports/RemoteConnectionProbing.swift | 149 +++- .../Lithe/Core/Ports/RuntimeLocator.swift | 28 + .../Core/Rust/RustBuildSystemOperations.swift | 2 + .../Rust/RustCoreBridge+RemoteWorkspace.swift | 63 ++ .../Lithe/Core/Rust/RustCoreBridge.swift | 38 +- .../RustLanguageServerRuntimeAdapter.swift | 4 + ...stRemoteConnectionOperations+Plugins.swift | 37 + ...stRemoteConnectionOperations+Process.swift | 39 + ...stRemoteConnectionOperations+Runtime.swift | 150 ++++ .../Rust/RustRemoteConnectionOperations.swift | 136 ++- .../Models/AppModel/AppModel+Terminal.swift | 18 +- .../Models/Runtime/ProjectRuntimeModels.swift | 7 +- .../Build/MacBuildProviderSessions.swift | 88 +- .../MacOS/Build/MacBuildToolResolver.swift | 134 ++- .../Platform/MacOS/MacServiceContainer.swift | 109 ++- ...acPluginLanguageServerLaunchResolver.swift | 8 + .../MacOS/Plugins/MacPluginPackageStore.swift | 15 +- .../Remote/MacRemoteBuildCommandRunner.swift | 85 ++ .../Remote/MacRemoteBuildStateLocator.swift | 68 ++ .../MacRemotePluginLaunchPreparer.swift | 198 +++++ .../MacRemotePluginPackageDeployer.swift | 246 ++++++ .../Remote/MacRemoteRuntimeLocator.swift | 239 +++++ .../Remote/MacRemoteShellDiscovery.swift | 82 ++ .../Remote/MacRemoteTerminalTransport.swift | 312 +++++++ .../MacOS/Runtime/MacRuntimeDiscovery.swift | 5 + .../Terminal/MacTerminalShellDiscovery.swift | 5 +- .../MacOS/Terminal/MacTerminalTransport.swift | 3 +- .../MacOS/Vcs/MacVcsProviderSessions.swift | 47 +- .../Services/Java/ProjectRuntimeService.swift | 73 +- .../Remote/RemoteDirectoryObservation.swift | 133 +++ .../Remote/RemoteWorkspaceRouting.swift | 123 ++- .../Lithe/Views/Terminal/TerminalView.swift | 6 +- .../Execution/BuildSystemContracts.swift | 40 +- .../LanguageServerRuntimeContracts.swift | 5 + .../Language/LanguageToolingContracts.swift | 2 + .../Services/BuildSystemService.swift | 71 +- .../Runtime/LanguageProviderRuntime.swift | 39 + .../Runtime/LanguageServerSession.swift | 297 +++++-- .../LanguageToolingSessionManager.swift | 2 +- .../Application/TerminalFeatureModel.swift | 99 ++- .../Module/TerminalModule.swift | 16 +- .../Ports/TerminalTransport.swift | 13 + .../Runtime/TerminalSession.swift | 88 +- .../TerminalModuleTests.swift | 42 + .../MacBuildProviderSessionsTests.swift | 204 ++++- .../RemoteBuildExecutionTests.swift | 156 ++++ .../LitheTests/RemoteConnectionsTests.swift | 82 +- .../RemoteDirectoryObservationTests.swift | 113 +++ .../RemotePluginDeploymentTests.swift | 166 ++++ .../RemotePluginLaunchPreparerTests.swift | 168 ++++ .../RemoteRuntimeDiscoveryTests.swift | 161 ++++ .../RemoteShellDiscoveryTests.swift | 80 ++ .../RemoteTerminalTransportTests.swift | 202 +++++ rust/Cargo.lock | 104 ++- rust/lithe-core/Cargo.toml | 3 + rust/lithe-core/src/build/commands.rs | 3 + rust/lithe-core/src/build/decode.rs | 31 +- .../src/build/integrations/maven.rs | 52 +- rust/lithe-core/src/build/invocation.rs | 10 +- rust/lithe-core/src/build/language_import.rs | 6 +- rust/lithe-core/src/build/model.rs | 4 +- rust/lithe-core/src/build/outputs.rs | 10 +- .../lithe-core/src/execution/configuration.rs | 23 +- .../src/execution/detectors/scan.rs | 18 +- .../src/execution/java_selection.rs | 16 +- rust/lithe-core/src/git/configuration.rs | 21 +- rust/lithe-core/src/git/execution_events.rs | 19 +- rust/lithe-core/src/git/history.rs | 44 +- rust/lithe-core/src/git/mod.rs | 134 ++- rust/lithe-core/src/git/patch_exchange.rs | 20 +- rust/lithe-core/src/git/rebase_session.rs | 15 +- rust/lithe-core/src/git/remote_history.rs | 196 +++++ rust/lithe-core/src/git/remote_process.rs | 330 +++++++ rust/lithe-core/src/git/rewrite.rs | 6 +- rust/lithe-core/src/lsp/interface/engine.rs | 68 +- rust/lithe-core/src/lsp/interface/mod.rs | 3 +- rust/lithe-core/src/lsp/interface/process.rs | 8 +- .../src/lsp/interface/remote_process.rs | 666 ++++++++++++++ rust/lithe-core/src/project/files.rs | 14 +- rust/lithe-core/src/project/maven.rs | 46 +- .../src/project/maven_dependency_tree.rs | 2 +- .../src/project/maven_test_reports.rs | 18 +- rust/lithe-core/src/project/search_index.rs | 27 +- rust/lithe-core/src/protocol/command.rs | 24 + rust/lithe-core/src/remote/mod.rs | 4 + rust/lithe-core/src/remote/sessions.rs | 62 +- .../src/remote/sessions_integration_tests.rs | 816 ++++++++++++++++++ rust/lithe-core/src/remote/tests.rs | 3 +- rust/lithe-core/src/remote/upload.rs | 142 +++ rust/lithe-core/src/remote/workspace.rs | 170 ++++ rust/lithe-core/src/remote/workspace_host.rs | 572 ++++++++++++ .../src/remote/workspace_host/tests.rs | 314 +++++++ rust/lithe-core/src/remote/workspace_io.rs | 624 ++++++++++++++ rust/lithe-core/src/runtime/dispatcher.rs | 69 +- .../lithe-core/src/tests/run_configuration.rs | 27 + rust/lithe-remote-client/src/api.rs | 1 + rust/lithe-remote-client/src/session.rs | 285 +++++- rust/lithe-remote-protocol/src/methods.rs | 55 ++ rust/lithe-remote-server/Cargo.toml | 1 + rust/lithe-remote-server/src/connection.rs | 130 ++- .../src/connection/tests.rs | 281 ++++++ rust/lithe-remote-server/src/daemon.rs | 36 +- rust/lithe-remote-server/src/fs.rs | 118 ++- rust/lithe-remote-server/src/lib.rs | 1 + rust/lithe-remote-server/src/platform/mod.rs | 1 + rust/lithe-remote-server/src/resources.rs | 114 ++- rust/lithe-remote-server/src/session.rs | 19 +- rust/lithe-remote-server/src/upload.rs | 203 +++++ rust/lithe-remote-server/tests/server.rs | 216 +++++ rust/lithe-remote-server/tests/terminals.rs | 8 +- scripts/reuse-worktree-resources.mjs | 5 + scripts/test-reuse-worktree-resources.mjs | 23 + scripts/worktree-resources.json | 25 +- .../contracts/remote-workspace-protocol-v1.md | 49 +- shared/contracts/rust-core-api.md | 38 + shared/i18n/locales/en-US.json | 20 +- shared/i18n/locales/zh-CN.json | 20 +- shared/i18n/messages.json | 205 +++++ .../remote-development-workspace.json | 19 +- .../features/remote-workspace.json | 19 +- 124 files changed, 10910 insertions(+), 558 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift create mode 100644 macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift create mode 100644 macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift create mode 100644 macos/Tests/LitheTests/RemoteBuildExecutionTests.swift create mode 100644 macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift create mode 100644 macos/Tests/LitheTests/RemotePluginDeploymentTests.swift create mode 100644 macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift create mode 100644 macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift create mode 100644 macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift create mode 100644 macos/Tests/LitheTests/RemoteTerminalTransportTests.swift create mode 100644 rust/lithe-core/src/git/remote_history.rs create mode 100644 rust/lithe-core/src/git/remote_process.rs create mode 100644 rust/lithe-core/src/lsp/interface/remote_process.rs create mode 100644 rust/lithe-core/src/remote/sessions_integration_tests.rs create mode 100644 rust/lithe-core/src/remote/upload.rs create mode 100644 rust/lithe-core/src/remote/workspace.rs create mode 100644 rust/lithe-core/src/remote/workspace_host.rs create mode 100644 rust/lithe-core/src/remote/workspace_host/tests.rs create mode 100644 rust/lithe-core/src/remote/workspace_io.rs create mode 100644 rust/lithe-remote-server/src/connection/tests.rs create mode 100644 rust/lithe-remote-server/src/upload.rs diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md index 1f8dd8d73e..ac4563deac 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -4,7 +4,7 @@ ## 先说结论 -远程文件夹以“本地占位根目录”的身份打开成工作区:根目录是 `Application Support/Lithe/RemoteWorkspaces/` 下的一个空目录,工作台照常拿到一个文件 URL;这个根目录下面的所有文件都在远端。文件树和编辑器经过的两个端口 `WorkspaceOperations` 和 `WorkspaceFileOperations` 外面各包了一层路由:URL 落在远程根目录下就交给远端,其余原样交给本地实现。第一版只让文件树、打开、编辑和保存走远端;Git、搜索、构建、语言服务和终端在远程工作区里看到的仍是本地空目录。 +远程文件夹以“本地占位根目录”的身份打开成工作区:根目录是 `Application Support/Lithe/RemoteWorkspaces/` 下的一个空目录,工作台照常拿到一个文件 URL;这个根目录下面的文件在远端。文件树与编辑器通过文件端口访问主机,Git、搜索和构建保留现有 Core 引擎、替换底层读写,终端把远端伪终端(PTY,负责交互进程与窗口尺寸)的字节交给本机渲染。JDK、Maven 和 Shell 的发现读取对应主机环境;通用插件部署与 stdio 已接入,远程 Java/SVN、Agent 执行位置选择与完整 SSH 工作流仍待完成和验收。 ## 问题 @@ -16,16 +16,30 @@ - **注册表**:`RemoteWorkspaceRegistry` 记录每个占位根对应的连接、远程路径和 Core 会话 id。打开远程项目时登记,同一个根再次打开时,旧会话会被替换并断开。 - **路由**:组合根(`MacServiceContainer`,负责组装依赖的地方)把本地实现包进 `RemoteRoutingWorkspaceOperations` 和 `RemoteRoutingFileOperations`。路由层对端口里的**每个**方法都显式转发:如果只覆盖一部分,没覆盖的方法会悄悄走协议扩展里的默认实现,本地文件的行为就变了。 - **文档版本**:和本地一样,文档标识是读到的原始字节的 SHA-256。远端用大小和修改时间做冲突检测,所以路由层为每个读到的标识记住远端版本(最多 512 个),保存时把它作为 `expected` 传给 `remote.writeFile`;不认识的标识会先读当前内容比对哈希。文件变了就返回冲突,不覆盖。 +- **文件树变更**:新文件只创建,不覆盖已存在文件;重命名、目录复制和删除都使用同一会话的主机文件协议,跨本机与远端或不同会话的移动和复制明确失败。复制先在目标父目录的私有暂存目录完成,再原子发布且拒绝覆盖,避免并发创建目标时丢失内容。界面的“移到废纸篓”复用 `trash` 上游库,在 Linux 使用 Freedesktop Trash、在远程 Mac 使用系统 Trash;不可用时失败,不改成永久删除。需要按内容标识保护的 `trashDocument` 仍待单独适配,不能假装普通 Trash 操作具有原子身份保护。 +- **外部修改监听**:`RemoteDirectoryObservation` 在工作线程订阅主机原生 watcher,转换工作区内路径后交给已有文件树、Git、搜索索引和文档刷新流程。Git common directory 位于工作区外时保留主机路径并额外监听。订阅先于创建,启动和溢出要求重新扫描,根目录移除结束订阅;取消先屏蔽旧 generation,再通过 Core 的资源所有者停止主机 watcher。打开的文档共用其工作区根的订阅,列表缓存随文档变更清空。 - **最近项目**:占位根目录不进“最近项目”,因为它离开连接就只是个空目录;重新打开要走“远程开发”窗口。 +- **Core 请求的主机身份**:桥接层只读取请求结构里明确的工作区根字段,附上注册表中的会话与两侧根目录;同一次 Git 操作涉及不同主机或本机与远端混合时拒绝执行。Core 在请求范围内持有这份身份,长期历史游标继续持有原连接,不依赖“当前选中主机”的全局变量。远端出错时返回失败,不访问本机占位目录或本机 Git。 +- **Git 与搜索继续复用现有引擎**:Git 参数、状态解析、提交、历史和搜索索引仍由原 Core 模块负责。文件与进程适配器只把请求送到项目所在主机;Git 可执行文件与版本按连接缓存,设置来源由同一主机 Git 返回。创建或管理 worktree、克隆时,仅映射类型化请求中明确的目标目录字段;原始 Git 参数、仓库地址和用户文本保持原样。搜索缓存增加连接身份,避免同一个占位 URL 曾经生成的本机空索引污染远端结果。远端服务不引入 Core、JDK、LSP 或 Agent Host;Linux musl 二进制各保持 1.5 MiB 上限与 panic unwind(线程出错时保留其他会话)。 +- **远端文件读写限制**:Core 一次装入内存的文件最多 32 MiB,分块读取还会核对版本、偏移与结束标记;流式快照哈希可读取更大的文件。原子写入沿用协议的 8 MiB 上限,超限在编码和发送前失败,不拆成会暴露半份内容的覆盖操作。文件或目录是否存在的查询必须保留权限、断线等失败,不能把它们当成“不存在”。临时目录清理失败记录错误类别,避免静默残留或把用户路径写入日志。 +- **终端确认与清理**:交互终端默认使用远端账户的 Shell,显式菜单选择保留独立 argv;调试进程按工作区身份选择主机,即使调试器给出的工作目录已经是远端绝对路径。启动在收到远端确认后才发布就绪与 PID,等待中的启动也算模块活动资源。重启先等待上次传输清理,输入按顺序发送,待发送尺寸只保留最新值;写入与缩放复用 Core 的操作取消,退出释放远程渲染标记,不把远端 PID 交给本机进程监控。 +- **运行时与 Shell 发现**:网络调用在平台工作线程运行,界面读取缓存;发现有数量、数据和时间限制,取消或切换工作区后丢弃旧结果,失败不发布部分结果或本机回退。Shell 从远端登录环境、`/etc/shells` 和文件权限发现,旧服务缺目标权限时再查询链接真实路径;本机终端设置里的可执行文件路径不作为远程默认值。 +- **资源生命周期**:Git 临时索引和源文件快照在远端临时目录或 Git common directory,由单次操作清理;探测、可执行文件与搜索缓存保存在客户端内存并按连接隔离。服务的 socket、锁和日志身份同时包含会话键与二进制 SHA-256,版本号相同但内容变化时不复用旧常驻进程。以上资源在 `scripts/worktree-resources.json` 中明确排除跨工作树复制,运行时不写安装资源,不改变签名和 Sparkle delta 基线。 +- **构建与插件运行**:构建扫描、工具探测、构建进程和报告读取使用原工作区的主机身份,stdout/stderr 分别增量解码。插件只部署经过原安装来源复核的包,异平台变体必须同版本、同声明且不增加批准程序;复用市场校验和下载器,不向小型服务引入包管理或语言引擎。目标文件通过带 SHA-256 的分块上传原子发布,普通文件只读、可执行文件只读可执行,可写状态位于远端用户目录。快照清理捕获原连接,在 Core 销毁进程后执行;完整快照的断线与崩溃回收仍待补齐,不能把上传暂存清理当成完整快照清理。 +- **stdio 与启动**:语言、构建和 VCS 提供者复用 Core 的协议引擎,远程通道保留有界输入输出与原连接。异步部署、工具探测和启动在工作线程进行,启动期间保留有界文档快照与文件变更,停止后到达的会话先销毁再删除包。仅映射协议定义的 URI 和 VCS 路径字段,参数、文本与插件自定义值保持原样。依赖本机原生集成的 Java 仍明确拒绝,不能伪装成通用 stdio 已支持。 +- **启动中取消不能依赖空资源表**:服务端的连接读取线程先登记进程、终端和监听的启动身份,再交给工作线程。后续关闭先记录在这个身份上,由创建者在发布资源后执行清理;即使关闭请求因工作线程已满返回 `BUSY`、客户端停止等待或断线,清理责任也不消失。关闭等待最多 60 秒,但到期不会撤销已经记录的清理;不同资源仍可并行,注册表锁不覆盖磁盘、启动或结束进程树。无效参数不能触发取消,后到的普通结束请求不能降低已请求的强制结束。这样修复的是所有通用后端的生命周期,不需要引入 Java/JDT 等语言专属语义。 +- **断线与会话结束不同**:常驻会话仅断线时保留终端和进程供重连;非驻留连接断开、显式关闭会话或空闲到期时,迟到发布的资源必须由创建者清理。结束标记在清理现有资源表之前设置,否则慢启动可能在清理后才出现,尤其是常驻会话不能再把它当成“等待重连”的合法资源。 ### 正确做法 新增一种需要读写工作区文件的能力时,通过这两个端口访问文件,远程工作区就会自动可用。 +例如搜索使用已有索引语义,通过主机文件适配器读取内容;终端接收 `started` 确认后再把远端 PID 返回给调试器。新增功能仍需逐个检查调用链,文件端口本身不能让本机进程自动变成远端进程。 ### 不要这样做 - 不要在功能代码里用 `FileManager` 直接读工作区下的文件:在远程工作区里它只会看到空目录。 - 不要从占位根目录推导“这是远程项目”再写特殊分支,应该询问 `RemoteWorkspaceRegistry`。 +- 不要把本机签名插件包内的 macOS 可执行文件路径直接传给 Linux;包的目标平台、资源部署和可写状态目录尚未适配时,必须保留失败状态,不宣称插件可用。 ## 考虑过的备选方案 @@ -37,16 +51,32 @@ 现有功能全都能直接用,但大项目同步慢、两边会不一致,构建和运行也会发生在本地而不是项目所在的机器上,和整体远程开发设计(构建、LSP 都在远端运行)相冲突。 +### 客户端取消时只重试关闭,或等待启动确认后才允许取消 + +只重试关闭仍可能在资源登记前用尽客户端的两秒清理期限;无限等待启动确认又会拖住关闭窗口和模块停用。也不能把慢启动改成连接读取线程上的同步工作,那会阻塞其他会话。采用服务端先记住取消、创建者随后清理的方式,让客户端保持有界等待,同时不丢失最终清理责任。 + ## 后果 - 远程工作区第一版:文件树按需列目录、打开和保存 UTF-8 文件(含 BOM)、保存时检测冲突。 -- 暂不支持:新建、删除、重命名、复制(报“不支持”);外部修改监听;Git、搜索、构建、语言服务和远端终端。这些要逐个接到 `lithe-remote-client` 已有的远端能力上。 +- 新增适配覆盖:远端 PTY、按主机探测环境、Git 文件与进程 I/O、搜索索引与 Shift+Shift 的文件及内容查询。真实服务 fixture 已覆盖状态、暂存、分页历史、设置来源、worktree 创建和管理、克隆、保留无关暂存的选择文件提交,以及未保存缓冲区的行历史;不能据此把完整真实 SSH 产品流程标为已验收。 +- 文件树新建、复制、重命名、移到主机废纸篓和普通删除已接入;外部修改监听覆盖文件树与文档,真实 SSH 的原生 watcher 仍需产品验收。 +- 构建 I/O、工具与状态目录适配,以及通用插件部署和 stdio 提供者接入已有实现与 fixture;真实 SSH 的完整构建、插件启动与取消链路仍待验收。 +- 仍待完成:远程 Java 原生集成、SVN 的兼容目标包与完整 VCS 流程、Agent 执行位置选择、完整快照断线/崩溃回收,以及 stdio 断线、顺序、背压与取消的真实主机覆盖。当前分支按 Draft PR 分阶段评审,不关闭 #1507。 - 连接在工作区关闭后仍保留,直到 App 退出。 +- 代价:主机调用增加往返时间,远端无有效工具或连接中断时必须明确失败;缓存与长期资源都需要保留原连接身份,不能复用本机路径命中的结果。 +- 当前限制:Git 原子写入仍保留 8 MiB 上限;新分块上传仅用于已批准插件文件,不代表 Git 大文件临时内容已经接入。 ## 验证 ```bash ./scripts/test-macos.sh --filter RemoteConnections +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemoteWorkspaceRoutingTests|RemoteDirectoryObservationTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemoteRuntimeDiscoveryTests|RemoteShellDiscoveryTests|RemoteTerminalTransportTests|TerminalModuleTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'RemotePluginDeploymentTests|RemotePluginLaunchPreparerTests|RemoteBuildExecutionTests|MacBuildProviderSessionsTests|MacVcsProviderSessionsTests' +./scripts/verify-remote-workspace.sh +node scripts/test-reuse-worktree-resources.mjs +./scripts/verify-runtime-bundle-immutability.sh +./scripts/verify-platform-feature-matrix.sh ./scripts/verify-service-boundaries.sh ``` @@ -56,3 +86,9 @@ - `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteWorkspaceRoots.swift` - `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` - `macos/Sources/Lithe/Models/AppModel/AppModel+RemoteConnections.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift` +- `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift` +- `rust/lithe-core/src/remote/workspace.rs` +- `rust/lithe-core/src/remote/workspace_host.rs` +- `rust/lithe-core/src/remote/workspace_io.rs` diff --git a/docs/ci-builds.md b/docs/ci-builds.md index dbc151d0a4..60b3b168f8 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -395,6 +395,30 @@ HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平 `remote` 中记住的密码或口令、`Application Support/Lithe/RemoteWorkspaces/` 下打开远程文件夹时 创建的空占位根目录,以及远端主机上 `~/.lithe-server/bin/` 中按 SHA-256 校验后复用的服务。 它们属于用户资料或远端主机,没有构建身份,所有复用阶段均拒绝复制。 +远端常驻服务的 socket、锁和日志使用“会话键 + 完整二进制 SHA-256”的散列作为身份; +即使版本号不变,重新构建后也不会连回旧二进制启动的服务。这些路径仍属于远端用户状态, +不复制进工作树或应用安装目录。 + +远程 Git 的临时索引、行历史快照,以及连接内存中的运行时、Shell、Git 可执行文件和搜索索引 +登记为 `remote-workspace-session-state`。临时文件通过主机的临时目录或 Git common directory +创建,由单次操作清理;本地占位根目录不保存远端文件。探测与索引快照按连接隔离,显式刷新或 +替换连接后重建。它们没有可校验的构建身份,所有复用阶段在调用校验器前拒绝复制。 +运行时写入仅发生在远端可写目录或进程内存中,不影响签名、安装资源或 Sparkle delta。 +文件树复制还会在远端目标父目录创建 `.lithe-copy--/` 私有暂存目录, +操作完成后清理;它与文件监听的连接资源同属 `remote-workspace-session-state`,没有复用阶段。 +移到废纸篓的内容和恢复元数据属于主机账户(Linux 的 XDG 数据目录/卷废纸篓,macOS 的系统 Trash), +由系统和用户保管,不是缓存或构建输入,也不复制到本机工作树。 + +远端构建输出、插件运行快照和分块上传同样登记在 `remote-workspace-session-state`, +没有跨工作树复制阶段。上传在目标父目录用 `.lithe-upload--` 暂存, +按连接持有,失败、断线或五分钟期限后删除;SHA-256 与架构校验只授权本次运行。 +完整插件快照位于远端 `~/.lithe-server/plugin-runs//package`,进程销毁后释放; +客户端崩溃或断线后的完整快照回收仍为 TODO。插件配置、索引等持久状态位于远端 +`~/.lithe-server/state/plugins////workspaces/`, +由该工作区和提供者持有,构建临时输出按运行清理。异平台包下载复用市场下载器, +在平台临时目录解压,传输完成或失败后删除;校验来源包括市场归档 SHA-256、安装来源、 +已批准命令、声明逐字节一致性和签名。上述内容没有版本/平台/架构/工具链构建 stamp, +复用脚本在校验和复制前拒绝这些路径;运行时不改写本机或远端安装资源,不影响签名或 Sparkle delta。 MCP 编辑器、工具面板和指令页的原生验收截图分别输出到 `.artifacts/test-stability/mcp-ui/` 与 `.artifacts/test-stability/instructions-ui/`,随 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 6002dada2a..08267096e5 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -137,6 +137,8 @@ "8 spaces" = "8 spaces"; "; " = "; "; "A ChangeList with this name already exists." = "A ChangeList with this name already exists."; +"A Git operation cannot span different workspace hosts." = "A Git operation cannot span different workspace hosts."; +"A Git operation cannot span local and remote workspaces." = "A Git operation cannot span local and remote workspaces."; "A Hook with this name already exists." = "A Hook with this name already exists."; "A Java cache-retention target is not a removable directory." = "A Java cache-retention target is not a removable directory."; "A Java project with no build tool, Maven, or Gradle." = "A Java project with no build tool, Maven, or Gradle."; @@ -538,6 +540,7 @@ "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)." = "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)."; "Check the MCP command, URL and headers." = "Check the MCP command, URL and headers."; "Check the configuration format, API URL and model. Each configuration must be under 64 KiB." = "Check the configuration format, API URL and model. Each configuration must be under 64 KiB."; +"Check the workspace connection and refresh the available runtimes." = "Check the workspace connection and refresh the available runtimes."; "Check your internet connection and try again later." = "Check your internet connection and try again later."; "Check: %@" = "Check: %@"; "Checked %@" = "Checked %@"; @@ -932,6 +935,8 @@ "Could not connect to the Debug Adapter: %@" = "Could not connect to the Debug Adapter: %@"; "Could not connect to the Java Debug Server: %@" = "Could not connect to the Java Debug Server: %@"; "Could not copy your Stable settings" = "Could not copy your Stable settings"; +"Could not discover runtimes on the remote host. Check the connection and refresh." = "Could not discover runtimes on the remote host. Check the connection and refresh."; +"Could not discover shells on the remote host. Check the connection and try again." = "Could not discover shells on the remote host. Check the connection and try again."; "Could not find the Java source for %@" = "Could not find the Java source for %@"; "Could not import MCP JSON. Check the mcpServers object and try again." = "Could not import MCP JSON. Check the mcpServers object and try again."; "Could not inspect saved file" = "Could not inspect saved file"; @@ -1732,6 +1737,7 @@ "Install community plugin “%@”?" = "Install community plugin “%@”?"; "Install from repository" = "Install from repository"; "Install the selected channel" = "Install the selected channel"; +"Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes."; "Installation" = "Installation"; "Installation: %@" = "Installation: %@"; "Installed" = "Installed"; @@ -2606,6 +2612,7 @@ "Plugin %@ from the official repository is not signed by Lithe, so it was not installed." = "Plugin %@ from the official repository is not signed by Lithe, so it was not installed."; "Plugin %@ has been removed from Lithe. Uninstall the old package." = "Plugin %@ has been removed from Lithe. Uninstall the old package."; "Plugin %@ has no Lithe signature. Community plugins require confirmation before installation." = "Plugin %@ has no Lithe signature. Community plugins require confirmation before installation."; +"Plugin %@ has no approved package for %@. Install a compatible plugin version." = "Plugin %@ has no approved package for %@. Install a compatible plugin version."; "Plugin %@ has no previous version to restore." = "Plugin %@ has no previous version to restore."; "Plugin %@ has no setting %@." = "Plugin %@ has no setting %@."; "Plugin %@ is an unsigned development package. Only development builds of Lithe can load it." = "Plugin %@ is an unsigned development package. Only development builds of Lithe can load it."; @@ -2892,6 +2899,7 @@ "Remote Project" = "Remote Project"; "Remote URL" = "Remote URL"; "Remote folder" = "Remote folder"; +"Remote pending documents exceeded the startup limit. Wait for the language server to start." = "Remote pending documents exceeded the startup limit. Wait for the language server to start."; "Remote: %@" = "Remote: %@"; "Removal pending" = "Removal pending"; "Remove" = "Remove"; @@ -3126,6 +3134,7 @@ "Runs on %@ shipped with the plugin" = "Runs on %@ shipped with the plugin"; "Runs the %@ installed on this Mac through the %@ adapter." = "Runs the %@ installed on this Mac through the %@ adapter."; "Runtime" = "Runtime"; +"Runtime discovery failed" = "Runtime discovery failed"; "Runtime used by the language server (installed on this Mac)" = "Runtime used by the language server (installed on this Mac)"; "Rust Core cache-retention planning is unavailable." = "Rust Core cache-retention planning is unavailable."; "Rust Core returned a Java cache-retention key that was not observed." = "Rust Core returned a Java cache-retention key that was not observed."; @@ -3435,6 +3444,7 @@ "Solidity Support" = "Solidity Support"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; +"Some special files could not be copied" = "Some special files could not be copied"; "Sort by Last Updated" = "Sort by Last Updated"; "Sort by Name" = "Sort by Name"; "Sort plugins" = "Sort plugins"; @@ -3828,12 +3838,18 @@ "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually." = "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually."; "The pull request will return to the open list and can receive new reviews." = "The pull request will return to the open list and can receive new reviews."; "The rebase was aborted. Inspect the branch and working files before continuing your work." = "The rebase was aborted. Inspect the branch and working files before continuing your work."; +"The remote plugin package changes approved commands or permissions. Install and approve that version first." = "The remote plugin package changes approved commands or permissions. Install and approve that version first."; +"The remote plugin package is not part of the approved installation." = "The remote plugin package is not part of the approved installation."; +"The remote terminal closed before it started." = "The remote terminal closed before it started."; +"The remote terminal is busy. Wait before sending more input." = "The remote terminal is busy. Wait before sending more input."; +"The remote terminal must be started asynchronously." = "The remote terminal must be started asynchronously."; "The reported file changes have been updated. Review them again before rolling back." = "The reported file changes have been updated. Review them again before rolling back."; "The repository changed or another Git operation is running. Preview the patch again." = "The repository changed or another Git operation is running. Preview the patch again."; "The repository changed or another Git operation is running. Refresh before retrying." = "The repository changed or another Git operation is running. Refresh before retrying."; "The repository changed or another Git operation is running. Refresh the preview to retry." = "The repository changed or another Git operation is running. Refresh the preview to retry."; "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; "The request has no secret." = "The request has no secret."; +"The required tool is not available on the remote host." = "The required tool is not available on the remote host."; "The response stopped at the model's token limit." = "The response stopped at the model's token limit."; "The selected AI provider has an invalid API URL or model." = "The selected AI provider has an invalid API URL or model."; "The selected AI provider has no API key." = "The selected AI provider has no API key."; @@ -3867,6 +3883,7 @@ "The staged changes will be committed and the current branch will be pushed to its configured remote." = "The staged changes will be committed and the current branch will be pushed to its configured remote."; "The stopped source file is no longer available: %@" = "The stopped source file is no longer available: %@"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; +"The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace."; "The test file is outside the current workspace: %@" = "The test file is outside the current workspace: %@"; "The update could not be installed. Download the release manually and try again." = "The update could not be installed. Download the release manually and try again."; "The update package could not be downloaded. Check your internet connection and try again." = "The update package could not be downloaded. Check your internet connection and try again."; @@ -3950,6 +3967,7 @@ "This folder already belongs to a Git repository" = "This folder already belongs to a Git repository"; "This folder cannot be read." = "This folder cannot be read."; "This folder is empty" = "This folder is empty"; +"This host cannot receive a compatible plugin package. Update the remote server." = "This host cannot receive a compatible plugin package. Update the remote server."; "This page" = "This page"; "This panel needs exactly one matching plugin instance in the workspace." = "This panel needs exactly one matching plugin instance in the workspace."; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."; @@ -7360,8 +7378,18 @@ "native.remoteFetchFailed" = "Remote Fetch failed"; "native.remoteFetchSucceeded" = "Remote Fetch succeeded"; "native.remoteFolder" = "Remote folder"; +"native.remoteGitMixedHosts" = "A Git operation cannot span different workspace hosts."; +"native.remoteGitMixedLocal" = "A Git operation cannot span local and remote workspaces."; "native.remoteJVMTomcat" = "Remote JVM / Tomcat"; "native.remoteProject" = "Remote Project"; +"native.remoteRuntimeDiscoveryFailed" = "Could not discover runtimes on the remote host. Check the connection and refresh."; +"native.remoteRuntimeToolMissing" = "The required tool is not available on the remote host."; +"native.remoteRuntimeToolRecovery" = "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes."; +"native.remoteShellDiscoveryFailed" = "Could not discover shells on the remote host. Check the connection and try again."; +"native.remoteTerminalAsyncStart" = "The remote terminal must be started asynchronously."; +"native.remoteTerminalClosedBeforeStart" = "The remote terminal closed before it started."; +"native.remoteTerminalInputBusy" = "The remote terminal is busy. Wait before sending more input."; +"native.remoteTerminalInvalidDirectory" = "The terminal directory is outside the remote workspace."; "native.remoteURL" = "Remote URL"; "native.removalPending" = "Removal pending"; "native.removeAll" = "Remove All"; @@ -7593,6 +7621,8 @@ "native.runsTests" = "Runs %1$@ tests"; "native.runsTheInstalledOnThisMacThroughTheAdapter" = "Runs the %1$@ installed on this Mac through the %2$@ adapter."; "native.runtime" = "Runtime"; +"native.runtimeDiscoveryFailed" = "Runtime discovery failed"; +"native.runtimeDiscoveryRecovery" = "Check the workspace connection and refresh the available runtimes."; "native.runtimeUsedByTheLanguageServerInstalledOnThisMac" = "Runtime used by the language server (installed on this Mac)"; "native.rustCoreCacheRetentionPlanningIsUnavailable" = "Rust Core cache-retention planning is unavailable."; "native.rustCoreReturnedAJavaCacheRetentionKey" = "Rust Core returned a Java cache-retention key that was not observed."; @@ -8840,6 +8870,12 @@ "punctuation and symbols" = "punctuation and symbols"; "quickOpen.filesCount" = "%1$lld files"; "rebase" = "rebase"; +"remote.copyIncomplete" = "Some special files could not be copied"; +"remote.pendingDocumentsLimit" = "Remote pending documents exceeded the startup limit. Wait for the language server to start."; +"remote.pluginApprovalRequired" = "The remote plugin package changes approved commands or permissions. Install and approve that version first."; +"remote.pluginHostUnsupported" = "This host cannot receive a compatible plugin package. Update the remote server."; +"remote.pluginPlatformUnavailable" = "Plugin %1$@ has no approved package for %2$@. Install a compatible plugin version."; +"remote.pluginUnapproved" = "The remote plugin package is not part of the approved installation."; "results" = "results"; "revert" = "revert"; "run configuration" = "run configuration"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 5dc98fccd4..9d1c64e934 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -137,6 +137,8 @@ "8 spaces" = "8 个空格"; "; " = ";"; "A ChangeList with this name already exists." = "已存在同名的更改列表。"; +"A Git operation cannot span different workspace hosts." = "一次 Git 操作不能跨越不同的工作区主机。"; +"A Git operation cannot span local and remote workspaces." = "一次 Git 操作不能同时跨越本地和远程工作区。"; "A Hook with this name already exists." = "已存在同名 Hook。"; "A Java cache-retention target is not a removable directory." = "Java 缓存保留目标不是可删除的目录。"; "A Java project with no build tool, Maven, or Gradle." = "不使用构建工具,或使用 Maven、Gradle 的 Java 项目。"; @@ -538,6 +540,7 @@ "Check the Hook name, command, event, Agents and timeout (1–86400 seconds)." = "请检查 Hook 名称、命令、事件、Agent 和超时(1–86400 秒)。"; "Check the MCP command, URL and headers." = "请检查 MCP 命令、URL 和请求头。"; "Check the configuration format, API URL and model. Each configuration must be under 64 KiB." = "请检查配置格式、API 地址和模型,每份配置不得超过 64 KiB。"; +"Check the workspace connection and refresh the available runtimes." = "请检查工作区连接并刷新可用运行环境。"; "Check your internet connection and try again later." = "请检查网络连接,稍后再试。"; "Check: %@" = "检查:%@"; "Checked %@" = "%@检查"; @@ -932,6 +935,8 @@ "Could not connect to the Debug Adapter: %@" = "无法连接调试适配器:%@"; "Could not connect to the Java Debug Server: %@" = "无法连接 Java Debug Server:%@"; "Could not copy your Stable settings" = "无法复制正式版设置"; +"Could not discover runtimes on the remote host. Check the connection and refresh." = "无法探测远程主机的运行环境,请检查连接后刷新。"; +"Could not discover shells on the remote host. Check the connection and try again." = "无法探测远程主机上的 Shell。请检查连接后重试。"; "Could not find the Java source for %@" = "找不到 %@ 对应的 Java 源码"; "Could not import MCP JSON. Check the mcpServers object and try again." = "无法导入 MCP JSON。请检查 mcpServers 对象后重试。"; "Could not inspect saved file" = "无法读取已保存的文件"; @@ -1732,6 +1737,7 @@ "Install community plugin “%@”?" = "安装社区插件“%@”?"; "Install from repository" = "从仓库安装"; "Install the selected channel" = "安装所选渠道版本"; +"Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。"; "Installation" = "安装来源"; "Installation: %@" = "安装来源:%@"; "Installed" = "已安装"; @@ -2606,6 +2612,7 @@ "Plugin %@ from the official repository is not signed by Lithe, so it was not installed." = "官方仓库中的插件 %@ 没有 Lithe 签名,因此未安装。"; "Plugin %@ has been removed from Lithe. Uninstall the old package." = "插件 %@ 已从 Lithe 中移除。请卸载旧的插件包。"; "Plugin %@ has no Lithe signature. Community plugins require confirmation before installation." = "插件 %@ 没有 Lithe 签名。社区插件安装前需要确认。"; +"Plugin %@ has no approved package for %@. Install a compatible plugin version." = "插件 %@ 没有适用于 %@ 的已批准包,请安装兼容版本。"; "Plugin %@ has no previous version to restore." = "插件 %@ 没有可恢复的上一个版本。"; "Plugin %@ has no setting %@." = "插件 %@ 没有设置项 %@。"; "Plugin %@ is an unsigned development package. Only development builds of Lithe can load it." = "插件 %@ 是未签名的开发包。只有 Lithe 的开发版本可以加载它。"; @@ -2892,6 +2899,7 @@ "Remote Project" = "远程项目"; "Remote URL" = "远程地址"; "Remote folder" = "远程文件夹"; +"Remote pending documents exceeded the startup limit. Wait for the language server to start." = "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。"; "Remote: %@" = "远程:%@"; "Removal pending" = "等待移除"; "Remove" = "移除"; @@ -3126,6 +3134,7 @@ "Runs on %@ shipped with the plugin" = "使用插件自带的 %@ 运行"; "Runs the %@ installed on this Mac through the %@ adapter." = "通过 %2$@ 适配器驱动这台 Mac 上已安装的 %1$@。"; "Runtime" = "运行时长"; +"Runtime discovery failed" = "运行环境探测失败"; "Runtime used by the language server (installed on this Mac)" = "语言服务器使用的运行时(本机已安装)"; "Rust Core cache-retention planning is unavailable." = "Rust Core 的缓存保留规划不可用。"; "Rust Core returned a Java cache-retention key that was not observed." = "Rust Core 返回了未被观察到的 Java 缓存保留键。"; @@ -3435,6 +3444,7 @@ "Solidity Support" = "Solidity 支持"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; +"Some special files could not be copied" = "部分特殊文件未能复制"; "Sort by Last Updated" = "按最近更新排序"; "Sort by Name" = "按名称排序"; "Sort plugins" = "插件排序"; @@ -3828,12 +3838,18 @@ "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually." = "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。"; "The pull request will return to the open list and can receive new reviews." = "该拉取请求将回到打开列表,并可继续接收审查。"; "The rebase was aborted. Inspect the branch and working files before continuing your work." = "变基已中止。继续工作前请检查分支和工作文件。"; +"The remote plugin package changes approved commands or permissions. Install and approve that version first." = "远程插件包更改了已批准的命令或权限,请先安装并批准该版本。"; +"The remote plugin package is not part of the approved installation." = "远程插件包不属于已批准的安装。"; +"The remote terminal closed before it started." = "远程终端在启动完成前已关闭。"; +"The remote terminal is busy. Wait before sending more input." = "远程终端正忙,请稍后再发送输入。"; +"The remote terminal must be started asynchronously." = "远程终端需要异步启动。"; "The reported file changes have been updated. Review them again before rolling back." = "Agent 上报的文件修改已更新,请重新确认后再回撤。"; "The repository changed or another Git operation is running. Preview the patch again." = "仓库已更改或有其他 Git 操作正在运行。请重新预览补丁。"; "The repository changed or another Git operation is running. Refresh before retrying." = "仓库已更改或有其他 Git 操作正在运行。请刷新后重试。"; "The repository changed or another Git operation is running. Refresh the preview to retry." = "仓库已更改或有其他 Git 操作正在运行。请刷新预览后重试。"; "The repository will be opened in detached HEAD state." = "仓库将以 detached HEAD 状态打开。"; "The request has no secret." = "请求不包含密钥。"; +"The required tool is not available on the remote host." = "远程主机上没有可用的所需工具。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; "The selected AI provider has an invalid API URL or model." = "所选 AI 服务商的 API 地址或模型无效。"; "The selected AI provider has no API key." = "所选 AI 服务商没有 API 密钥。"; @@ -3867,6 +3883,7 @@ "The staged changes will be committed and the current branch will be pushed to its configured remote." = "暂存的更改将被提交,并推送当前分支到已配置的远程仓库。"; "The stopped source file is no longer available: %@" = "程序停住的源文件已不存在:%@"; "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; +"The terminal directory is outside the remote workspace." = "终端目录不在远程工作区内。"; "The test file is outside the current workspace: %@" = "测试文件在当前工作区之外:%@"; "The update could not be installed. Download the release manually and try again." = "无法安装更新,请手动下载版本后重试。"; "The update package could not be downloaded. Check your internet connection and try again." = "无法下载更新包,请检查网络连接后重试。"; @@ -3950,6 +3967,7 @@ "This folder already belongs to a Git repository" = "此目录已属于一个 Git 仓库。"; "This folder cannot be read." = "无法读取此文件夹。"; "This folder is empty" = "此文件夹为空"; +"This host cannot receive a compatible plugin package. Update the remote server." = "此主机无法接收兼容的插件包,请更新远程服务。"; "This page" = "此页面"; "This panel needs exactly one matching plugin instance in the workspace." = "此面板要求工作区中恰好有一个匹配的插件实例。"; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; @@ -7360,8 +7378,18 @@ "native.remoteFetchFailed" = "远程 Fetch 失败"; "native.remoteFetchSucceeded" = "远程 Fetch 成功"; "native.remoteFolder" = "远程文件夹"; +"native.remoteGitMixedHosts" = "一次 Git 操作不能跨越不同的工作区主机。"; +"native.remoteGitMixedLocal" = "一次 Git 操作不能同时跨越本地和远程工作区。"; "native.remoteJVMTomcat" = "远程 JVM / Tomcat"; "native.remoteProject" = "远程项目"; +"native.remoteRuntimeDiscoveryFailed" = "无法探测远程主机的运行环境,请检查连接后刷新。"; +"native.remoteRuntimeToolMissing" = "远程主机上没有可用的所需工具。"; +"native.remoteRuntimeToolRecovery" = "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。"; +"native.remoteShellDiscoveryFailed" = "无法探测远程主机上的 Shell。请检查连接后重试。"; +"native.remoteTerminalAsyncStart" = "远程终端需要异步启动。"; +"native.remoteTerminalClosedBeforeStart" = "远程终端在启动完成前已关闭。"; +"native.remoteTerminalInputBusy" = "远程终端正忙,请稍后再发送输入。"; +"native.remoteTerminalInvalidDirectory" = "终端目录不在远程工作区内。"; "native.remoteURL" = "远程地址"; "native.removalPending" = "等待移除"; "native.removeAll" = "全部移除"; @@ -7593,6 +7621,8 @@ "native.runsTests" = "运行 %1$@ 测试"; "native.runsTheInstalledOnThisMacThroughTheAdapter" = "通过 %2$@ 适配器驱动这台 Mac 上已安装的 %1$@。"; "native.runtime" = "运行时长"; +"native.runtimeDiscoveryFailed" = "运行环境探测失败"; +"native.runtimeDiscoveryRecovery" = "请检查工作区连接并刷新可用运行环境。"; "native.runtimeUsedByTheLanguageServerInstalledOnThisMac" = "语言服务器使用的运行时(本机已安装)"; "native.rustCoreCacheRetentionPlanningIsUnavailable" = "Rust Core 的缓存保留规划不可用。"; "native.rustCoreReturnedAJavaCacheRetentionKey" = "Rust Core 返回了未被观察到的 Java 缓存保留键。"; @@ -8840,6 +8870,12 @@ "punctuation and symbols" = "标点与符号"; "quickOpen.filesCount" = "%1$lld 个文件"; "rebase" = "变基"; +"remote.copyIncomplete" = "部分特殊文件未能复制"; +"remote.pendingDocumentsLimit" = "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。"; +"remote.pluginApprovalRequired" = "远程插件包更改了已批准的命令或权限,请先安装并批准该版本。"; +"remote.pluginHostUnsupported" = "此主机无法接收兼容的插件包,请更新远程服务。"; +"remote.pluginPlatformUnavailable" = "插件 %1$@ 没有适用于 %2$@ 的已批准包,请安装兼容版本。"; +"remote.pluginUnapproved" = "远程插件包不属于已批准的安装。"; "results" = "个结果"; "revert" = "反向提交"; "run configuration" = "运行配置"; diff --git a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift index 6c8fd7c492..6cc292757a 100644 --- a/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift +++ b/macos/Sources/Lithe/Core/Ports/RemoteConnectionProbing.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts // Note: SSH 连接配置与测试连接见 .agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md @@ -170,12 +171,16 @@ struct RemoteDirectoryEntry: Decodable, Equatable, Identifiable, Sendable { let size: Int64? /// Modification time in milliseconds since the Unix epoch. let modifiedMs: Int64? + let mode: UInt32? + let symlinkTargetMode: UInt32? - init(kind: String, symlinkTargetKind: String? = nil, size: Int64? = nil, modifiedMs: Int64? = nil) { + init(kind: String, symlinkTargetKind: String? = nil, size: Int64? = nil, modifiedMs: Int64? = nil, mode: UInt32? = nil, symlinkTargetMode: UInt32? = nil) { self.kind = kind self.symlinkTargetKind = symlinkTargetKind self.size = size self.modifiedMs = modifiedMs + self.mode = mode + self.symlinkTargetMode = symlinkTargetMode } } @@ -228,4 +233,146 @@ protocol RemoteWorkspaceTransport: Sendable { func writeFile( _ data: Data, to path: String, expected: RemoteFileVersion?, session id: String ) throws -> RemoteWriteOutcome + func mutate(_ mutation: RemoteFileMutation, session: String) throws + func watch( + root: String, session: String, + onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void + ) async throws +} + +enum RemoteFileMutation: Sendable, Equatable { + case createDirectory(path: String, recursive: Bool) + case copy(from: String, to: String) + case move(from: String, to: String) + case remove(path: String) + case trash(path: String) +} + +enum RemoteWatchEvent: Sendable, Equatable { + /// A new subscription needs a rescan to cover the startup window. + case started + case changed(paths: [String], overflow: Bool) + case invalidated(rootRemoved: Bool) +} + +extension RemoteWorkspaceTransport { + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + throw CocoaError(.featureUnsupported) + } + + func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + throw CocoaError(.featureUnsupported) + } +} + +/// A PTY on the workspace host. Environment contains explicit overrides only; +/// the remote account owns HOME, PATH, shell selection and login profiles. +struct RemoteTerminalRequest: Encodable, Equatable, Sendable { + let argv: [String]? + let cwd: String + let env: [String: String] + let columns: UInt16 + let rows: UInt16 +} + +enum RemoteTerminalEvent: Equatable, Sendable { + case started(id: UInt64, pid: Int32) + case output(Data) + case exited(Int32?) +} + +protocol RemoteTerminalConnecting: Sendable { + /// Owns the PTY until exit. Cancelling this task closes its entire process tree. + func runTerminal( + _ request: RemoteTerminalRequest, session: String, + onEvent: @escaping @Sendable (RemoteTerminalEvent) -> Void + ) async -> Result + func writeTerminal(_ data: Data, id: UInt64, session: String) async throws + func resizeTerminal(id: UInt64, columns: UInt16, rows: UInt16, session: String) async throws +} + +/// A non-interactive process with an explicit host working directory. +struct RemoteProcessRequest: Encodable, Equatable, Sendable { + let argv: [String] + let cwd: String + let env: [String: String] + let environment = "login" + let processId = 0 +} + +enum RemoteProcessEvent: Equatable, Sendable { + case output(Data, standardError: Bool) +} + +struct RemoteProcessResult: Decodable, Equatable, Sendable { + let exitCode: Int32? + let signal: Int32? +} + +protocol RemoteProcessRunning: Sendable { + /// Owns the process tree through exit, timeout, or cancellation. Output is + /// delivered in host order and stderr remains distinct from stdout. + func runProcess( + _ request: RemoteProcessRequest, session: String, timeout: Duration?, + onEvent: @escaping @Sendable (RemoteProcessEvent) -> Void + ) async throws -> RemoteProcessResult +} + +struct RemoteBuildFileMetadata: Equatable, Sendable { + let isExecutable: Bool +} + +protocol RemoteBuildFileProbing: Sendable { + /// Resolves links on the host. Only a missing file returns nil; transport + /// and permission failures remain failures. Calls belong on a worker. + func buildFileMetadata(_ path: String, session: String) throws -> RemoteBuildFileMetadata? +} + +/// Host-owned mutable build state. Blocking operations run on a platform worker. +protocol RemoteBuildStateAccess: Sendable { + func prepareBuildState(pluginID: String, buildSystemID: String, workspacePath: String, session: String) throws -> String + func removeBuildOutputFile(_ path: String, session: String) throws +} + +/// Immutable plugin snapshots use the same generic host filesystem as builds. +/// Trust and target-platform selection remain in the package adapter. +struct RemotePluginHost: Decodable, Equatable, Sendable { + let os: String + let arch: String + let homeDirectory: String? + let features: [String] + + var platform: String? { + let system: String + switch os { case "linux": system = "linux"; case "macos": system = "darwin"; default: return nil } + switch arch { case "x86_64": return system + "-x64"; case "aarch64": return system + "-arm64"; default: return nil } + } + + var templateArchitecture: String { arch == "aarch64" ? "arm64" : arch } +} + +protocol RemotePluginFileAccess: Sendable { + func pluginHost(session: String) async throws -> RemotePluginHost + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws +} + +protocol RemotePluginPackageDeploying: Sendable { + func buildRequiresPackage(pluginID: String, buildSystemID: String, packageURL: URL) throws -> Bool + func deploy(pluginID: String, packageURL: URL, session: String) async throws -> RemoteDeployedPluginPackage +} + +struct RemoteDeployedPluginPackage: Sendable { + let packageURL: URL + let manifest: Data + let architecture: String + let release: @Sendable () async -> Void +} + +/// Read-only shell discovery on one host. Calls block and belong on a worker. +protocol RemoteShellProbing: Sendable { + func shellEnvironment(session: String) throws -> [String: String] + func registeredShells(session: String) throws -> String + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] + func runtimeExecutable(_ path: String, session: String) throws -> Bool } diff --git a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift index a8324e054f..6132a5d98d 100644 --- a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift +++ b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift @@ -66,8 +66,15 @@ struct DefaultRuntimeToolDiscovery: RuntimeToolDiscovery { } protocol RuntimeLocator: Sendable { + /// A failed host probe must not be presented as an empty local toolchain. + var discoveryFailure: String? { get } + func normalizedPath(_ path: String, relativeTo root: URL?) -> String + func configureProbePaths(_ paths: [String]) + /// Remote adapters select from a host-read snapshot without UI-thread I/O. + func selectJavaRuntime(candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result? func environment() -> [String: String] func discover() -> RuntimeDiscoveryResult + func discover(isCancelled: @Sendable () -> Bool) -> RuntimeDiscoveryResult /// Java-only probing for launch paths; never runs Maven or other tools. func discoverJavaRuntimes() -> [JavaRuntimeCandidate] func discoverJavaRuntimes(isCancelled: @Sendable () -> Bool) -> [JavaRuntimeCandidate] @@ -84,6 +91,17 @@ protocol RuntimeLocator: Sendable { } extension RuntimeLocator { + var discoveryFailure: String? { nil } + func configureProbePaths(_ paths: [String]) {} + func selectJavaRuntime(candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result? { nil } + func normalizedPath(_ path: String, relativeTo root: URL?) -> String { + let normalized = ((path as NSString).expandingTildeInPath as NSString).standardizingPath + guard !(normalized as NSString).isAbsolutePath, let root else { return normalized } + return root.appendingPathComponent(normalized).standardizedFileURL.path + } + func discover(isCancelled: @Sendable () -> Bool) -> RuntimeDiscoveryResult { + isCancelled() ? RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) : discover() + } func invalidateProbeCache() {} func discoverJavaRuntimes(isCancelled: @Sendable () -> Bool) -> [JavaRuntimeCandidate] { isCancelled() ? [] : discoverJavaRuntimes() @@ -92,6 +110,16 @@ extension RuntimeLocator { func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? { nil } } +/// Synchronous host I/O used exclusively by runtime discovery's owned worker. +protocol RemoteRuntimeProbing: Sendable { + func runtimeEnvironment(session: String) throws -> [String: String] + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] + func runtimeRealPath(_ path: String, session: String) throws -> String + func runtimeExecutable(_ path: String, session: String) throws -> Bool + func runtimeVersion(_ executable: String, session: String) throws -> String + func runtimeRequirements(_ root: String, session: String) throws -> ToolingJSONValue +} + /// Shared selection policy; platform locators still own executable validation /// and version probing. Requirements are read through Core's existing document. protocol JavaRuntimeSelecting: Sendable { diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index 84b2ca699f..2b84905ebf 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -220,6 +220,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { payload: BuildPayload( manifest: try Self.json(target.manifest), buildSystemID: target.buildSystemID, + workspaceRoot: target.workspaceURL?.path, request: request ) ) @@ -274,6 +275,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { private struct BuildPayload: Encodable { let manifest: ToolingJSONValue let buildSystemID: String + let workspaceRoot: String? let request: Request } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift new file mode 100644 index 0000000000..0599c1b173 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+RemoteWorkspace.swift @@ -0,0 +1,63 @@ +import Foundation +import LitheModuleAPI + +extension RustCoreBridge { + /// Only schema-owned root fields select a host. Commit messages, command + /// arguments and user text must never be interpreted as filesystem paths. + func remoteWorkspace( + for command: String, payload: Payload + ) throws -> RemoteWorkspace? { + guard let remoteWorkspaces, + command.hasPrefix("git.") || command.hasPrefix("build.") || [ + "workspace.repositories", "workspace.snapshot", "workspace.listDirectories", + "workspace.search", "workspace.searchEverywhere", "file.read", "file.write", + "workspace.searchIndex.warm", "workspace.searchIndex.update", "workspace.searchIndex.invalidate", + "workspace.replacePreview", "runConfig.selectJava", "lsp.startServer" + ].contains(command) else { return nil } + let encoded = try JSONEncoder().encode(payload) + guard let object = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { return nil } + var roots = [String]() + if let root = object["root"] as? String, !root.isEmpty { roots.append(root) } + if command.hasPrefix("build.") { + if let root = object["workspaceRoot"] as? String { roots.append(root) } + if let request = object["request"] as? [String: Any], + let root = request["workspaceRoot"] as? String { roots.append(root) } + } + if command == "lsp.startServer", let root = object["workingDirectory"] as? String { + roots.append(root) + } + roots.append(contentsOf: object["repositoryRoots"] as? [String] ?? []) + func appendRepositories(_ object: [String: Any]) { + let repositories = object["repositories"] as? [[String: Any]] ?? [] + roots.append(contentsOf: repositories.compactMap { $0["root"] as? String }) + } + appendRepositories(object) + if let session = object["session"] as? [String: Any], + let plan = session["plan"] as? [String: Any] { appendRepositories(plan) } + if let plan = object["plan"] as? [String: Any] { appendRepositories(plan) } + if let previous = object["previous"] as? [String: Any], + let plan = previous["plan"] as? [String: Any] { appendRepositories(plan) } + if let reviewed = object["reviewed"] as? [String: Any] { appendRepositories(reviewed) } + var selected: RemoteWorkspace? + var includesLocal = false + for root in roots { + guard let resolved = remoteWorkspaces.resolve(URL(fileURLWithPath: root)) else { + includesLocal = true + continue + } + let candidate = RemoteWorkspace(sessionId: resolved.binding.sessionID, + localRoot: resolved.binding.root.standardizedFileURL.path, + remoteRoot: resolved.binding.remoteRoot) + if let selected, selected != candidate { + throw CoreCallError(code: "not_supported", + message: L10n.string("A Git operation cannot span different workspace hosts."), details: nil) + } + selected = candidate + } + if selected != nil && includesLocal { + throw CoreCallError(code: "not_supported", + message: L10n.string("A Git operation cannot span local and remote workspaces."), details: nil) + } + return selected + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 4f837e5b44..db8ff621d8 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -12,6 +12,13 @@ import LitheRustCore struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { var gitPreferences: GitExecutionPreferences? = nil var gitExecutionJournal: GitExecutionJournal? = nil + var remoteWorkspaces: RemoteWorkspaceRegistry? = nil + + struct RemoteWorkspace: Encodable, Equatable { + let sessionId: String + let localRoot: String + let remoteRoot: String + } private struct Request: Encodable { let id: String @@ -20,6 +27,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let command: String let payload: Payload var gitExecution: GitExecutionOptions? = nil + var remoteWorkspace: RemoteWorkspace? = nil } private struct Envelope: Decodable { @@ -2806,6 +2814,20 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { )) } + /// Snapshot selection performs no filesystem access, including for a remote project. + func selectJavaRuntime( + requirements: ToolingJSONValue, candidates: [AutomaticJavaCandidate], fallbackID: String? + ) -> Result { + struct Payload: Encodable { + let requirements: ToolingJSONValue + let candidates: [AutomaticJavaCandidate] + let fallbackId: String? + } + return executeResult(command: "runConfig.selectJava", payload: Payload( + requirements: requirements, candidates: candidates, fallbackId: fallbackID + )) + } + func inspectRunConfiguration( at rootURL: URL, checkFingerprint: Bool? = nil, @@ -4181,12 +4203,14 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { command: String, payload: Payload, operationID: String? = nil, + timeoutMilliseconds: Int? = nil, onEvent: (@Sendable (String) -> Void)? = nil ) -> Result { let outcome: Result, CoreCallError> = decodeEnvelope( command: command, payload: payload, operationID: operationID, + timeoutMilliseconds: timeoutMilliseconds, onEvent: onEvent ) return outcome.flatMap { envelope in @@ -4216,6 +4240,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { command: String, payload: Payload, operationID: String? = nil, + timeoutMilliseconds: Int? = nil, onEvent: (@Sendable (String) -> Void)? = nil ) -> Result, CoreCallError> { guard isAvailable else { @@ -4234,14 +4259,23 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { var gitOptions = gitPreferences?.snapshot ?? GitExecutionOptions() gitOptions.interactive = execution != nil gitOptions.source = execution?.source ?? GitExecutionSource.current + let remoteWorkspace: RemoteWorkspace? + do { + remoteWorkspace = try self.remoteWorkspace(for: command, payload: payload) + } catch let failure as CoreCallError { + return .failure(failure) + } catch { + return .failure(CoreCallError(code: "invalid_request", message: "Invalid workspace request", details: nil)) + } guard let requestData = try? JSONEncoder().encode( Request( id: requestID, operationId: requestID, - timeoutMilliseconds: nil, + timeoutMilliseconds: timeoutMilliseconds, command: command, payload: payload, - gitExecution: (observesGit || command == "workspace.searchEverywhere") ? gitOptions : nil + gitExecution: (observesGit || command == "workspace.searchEverywhere") ? gitOptions : nil, + remoteWorkspace: remoteWorkspace ) ), let request = String(data: requestData, encoding: .utf8), diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 5047c42e63..befa305aea 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -3,6 +3,10 @@ import LitheCoreContracts import LitheModuleAPI extension RustCoreBridge: LanguageServerRuntimeCore { + func requiresBackgroundLanguageServerStart(rootURL: URL) -> Bool { + remoteWorkspaces?.resolve(rootURL) != nil + } + func startLanguageServer( providerID: String, executableURL: URL, diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift new file mode 100644 index 0000000000..7c98936959 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Plugins.swift @@ -0,0 +1,37 @@ +import Foundation +import LitheCoreContracts + +extension RustRemoteConnectionOperations: RemotePluginFileAccess { + func pluginHost(session: String) async throws -> RemotePluginHost { + let result: Result = await run( + "remote.hostInfo", payload: ["sessionId": session], onPrompt: { _ in }) + try Task.checkCancellation() + return try result.get() + } + + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue { + struct Payload: Encodable, Sendable { + let sessionId: String + let method: String + let params: ToolingJSONValue + } + let result: Result = await run( + "remote.hostRequest", payload: Payload(sessionId: session, method: method, params: params), onPrompt: { _ in }) + try Task.checkCancellation() + return try result.get() + } + + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws { + struct Payload: Encodable, Sendable { + let sessionId: String + let sourcePath: String + let path: String + let sha256: String + let mode: UInt32 + } + let result: Result = await run("remote.uploadFile", + payload: Payload(sessionId: session, sourcePath: source.path, path: path, sha256: sha256, mode: mode), onPrompt: { _ in }) + try Task.checkCancellation() + _ = try result.get() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift new file mode 100644 index 0000000000..4e50a19543 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Process.swift @@ -0,0 +1,39 @@ +import Foundation +import LitheCoreContracts + +extension RustRemoteConnectionOperations: RemoteProcessRunning { + func runProcess( + _ request: RemoteProcessRequest, session: String, timeout: Duration?, + onEvent: @escaping @Sendable (RemoteProcessEvent) -> Void + ) async throws -> RemoteProcessResult { + struct Payload: Encodable, Sendable { + let sessionId: String + let params: RemoteProcessRequest + let timeoutMilliseconds: UInt64? + } + struct Event: Decodable { + struct Parameters: Decodable { let stream: String; let data: String } + let type: String + let event: String + let params: Parameters + } + let milliseconds = timeout.map { duration -> UInt64 in + let parts = duration.components + let seconds = max(0, parts.seconds) + let fractional = max(0, parts.attoseconds / 1_000_000_000_000_000) + return UInt64(min(seconds, 86_400)) * 1_000 + UInt64(fractional) + } + let result: Result = await run( + "remote.runProcess", + payload: Payload(sessionId: session, params: request, timeoutMilliseconds: milliseconds), + onPrompt: { _ in }, onEvent: { raw in + guard let event = try? JSONDecoder().decode(Event.self, from: Data(raw.utf8)), + event.type == "remote", event.event == "process.output", + let bytes = Data(base64Encoded: event.params.data) else { return } + onEvent(.output(bytes, standardError: event.params.stream == "stderr")) + } + ) + try Task.checkCancellation() + return try result.get() + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift new file mode 100644 index 0000000000..af80ed56e9 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations+Runtime.swift @@ -0,0 +1,150 @@ +import CryptoKit +import Foundation +import LitheCoreContracts + +extension RustRemoteConnectionOperations: RemoteRuntimeProbing, RemoteShellProbing, RemoteBuildFileProbing, RemoteBuildStateAccess { + private struct HostProbe: Encodable { + let sessionId: String + let method: String + let params: Parameters + } + + private func runtimeRequest( + _ method: String, params: Parameters, session: String + ) throws -> Value { + let result: Result = core.executeResult( + command: "remote.hostRequest", payload: HostProbe(sessionId: session, method: method, params: params), + timeoutMilliseconds: 15_000 + ) + return try result.get() + } + + func runtimeEnvironment(session: String) throws -> [String: String] { + struct Environment: Decodable { let environment: [String: String] } + let value: Environment = try runtimeRequest("environment.resolve", params: [String: String](), session: session) + return value.environment + } + + func shellEnvironment(session: String) throws -> [String: String] { + struct Environment: Decodable { let environment: [String: String] } + let value: Environment = try runtimeRequest("environment.resolve", params: ["refresh": true], session: session) + return value.environment + } + + func registeredShells(session: String) throws -> String { + struct Content: Decodable { let data: String; let eof: Bool } + struct Parameters: Encodable { let path = "/etc/shells"; let length = 65_536 } + do { + let value: Content = try runtimeRequest("fs.readFile", params: Parameters(), session: session) + guard value.eof, let bytes = Data(base64Encoded: value.data), bytes.count <= 65_536, + let text = String(data: bytes, encoding: .utf8) else { + throw RustCoreBridge.CoreCallError(code: "parse_failed", message: "Invalid registered shells snapshot", details: nil) + } + return text + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + return "" + } + } + + func runtimeRequirements(_ root: String, session: String) throws -> ToolingJSONValue { + struct Parameters: Encodable { let path: String; let length = 262_144 } + struct Content: Decodable { + struct Metadata: Decodable { let size: Int } + let data: String + let metadata: Metadata + let eof: Bool + } + do { + let value: Content = try runtimeRequest( + "fs.readFile", params: Parameters(path: root + "/.lithe/toolchains/requirements.json"), session: session + ) + guard value.eof, value.metadata.size <= 262_144, let data = Data(base64Encoded: value.data) else { + throw RustCoreBridge.CoreCallError(code: "parse_failed", message: "Invalid toolchain requirements snapshot", details: nil) + } + return try JSONDecoder().decode(ToolingJSONValue.self, from: data) + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + return .object(["version": .integer(1), "toolchains": .object([:])]) + } + } + + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] { + struct Listing: Decodable { let entries: [RemoteDirectoryEntry] } + let value: Listing = try runtimeRequest("fs.readDir", params: ["path": path], session: session) + return value.entries + } + + func runtimeRealPath(_ path: String, session: String) throws -> String { + struct Resolved: Decodable { let path: String } + let value: Resolved = try runtimeRequest("fs.realPath", params: ["path": path], session: session) + return value.path + } + + func runtimeExecutable(_ path: String, session: String) throws -> Bool { + struct Metadata: Decodable { let kind: String; let mode: UInt32 } + let resolved = try runtimeRealPath(path, session: session) + let value: Metadata = try runtimeRequest("fs.stat", params: ["path": resolved], session: session) + return value.kind == "file" && value.mode & 0o111 != 0 + } + + func buildFileMetadata(_ path: String, session: String) throws -> RemoteBuildFileMetadata? { + struct Metadata: Decodable { let kind: String; let mode: UInt32 } + do { + let resolved = try runtimeRealPath(path, session: session) + let value: Metadata = try runtimeRequest("fs.stat", params: ["path": resolved], session: session) + return RemoteBuildFileMetadata(isExecutable: value.kind == "file" && value.mode & 0o111 != 0) + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + return nil + } + } + + func prepareBuildState(pluginID: String, buildSystemID: String, workspacePath: String, session: String) throws -> String { + let environment = try runtimeEnvironment(session: session) + guard let home = environment["HOME"], home.hasPrefix("/"), !home.contains("\0") else { + throw CocoaError(.fileNoSuchFile) + } + func digest(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + let path = URL(fileURLWithPath: home, isDirectory: true) + .appendingPathComponent(".lithe-server/state/plugins") + .appendingPathComponent(digest(pluginID)) + .appendingPathComponent("build-systems") + .appendingPathComponent(digest(buildSystemID)) + .appendingPathComponent("workspaces") + .appendingPathComponent(digest(workspacePath)).path + let _: ToolingJSONValue = try runtimeRequest("fs.createDir", params: ["path": ToolingJSONValue.string(path), "recursive": .bool(true), "mode": .integer(0o700)], session: session) + return path + } + + func removeBuildOutputFile(_ path: String, session: String) throws { + do { + let _: ToolingJSONValue = try runtimeRequest("fs.remove", params: ["path": ToolingJSONValue.string(path), "recursive": .bool(false)], session: session) + } catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { + // A command may finish without writing its optional output file. + } + } + + func runtimeVersion(_ executable: String, session: String) throws -> String { + struct Spawn: Encodable { + let processId = 0 + let argv: [String] + let environment = "login" + } + struct Capture: Encodable { + let sessionId: String + let params: Spawn + let timeoutMilliseconds = 5_000 + let maxOutputBytes = 262_144 + } + struct Output: Decodable { let stdout: String; let stderr: String; let exitCode: Int? } + let result: Result = core.executeResult( + command: "remote.captureProcess", + payload: Capture(sessionId: session, params: Spawn(argv: [executable, "-version"])), + timeoutMilliseconds: 7_000 + ) + let value = try result.get() + guard value.exitCode == 0, let stdout = Data(base64Encoded: value.stdout), + let stderr = Data(base64Encoded: value.stderr) else { return "" } + return String(decoding: stdout, as: UTF8.self) + "\n" + String(decoding: stderr, as: UTF8.self) + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift index f9df960eeb..8b27b090e7 100644 --- a/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustRemoteConnectionOperations.swift @@ -1,8 +1,10 @@ import Foundation +import LitheCoreContracts +import LitheModuleAPI /// Typed `remote.*` commands. Rust Core runs OpenSSH and /// relays its questions; this adapter only translates payloads and events. -struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionConnecting, RemoteWorkspaceTransport { +struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionConnecting, RemoteWorkspaceTransport, RemoteTerminalConnecting { let core: RustCoreBridge func probe( @@ -50,10 +52,11 @@ struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionCon /// Runs one blocking Core command off the caller's thread. Cancelling /// the calling task cancels the command. - private func run( + func run( _ command: String, payload: Payload, - onPrompt: @escaping @Sendable (RemoteAuthenticationPrompt) -> Void + onPrompt: @escaping @Sendable (RemoteAuthenticationPrompt) -> Void, + onEvent: @escaping @Sendable (String) -> Void = { _ in } ) async -> Result { let operationID = UUID().uuidString let cancellation = RustCoreCancellation(core: core, operationID: operationID) @@ -69,6 +72,7 @@ struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionCon operationID: operationID, onEvent: { event in cancellation.receive(event) + onEvent(event) if let prompt = Self.prompt(from: event) { onPrompt(prompt) } } ) @@ -125,6 +129,132 @@ struct RustRemoteConnectionOperations: RemoteConnectionProbing, RemoteSessionCon return .saved(RemoteFileVersion(size: size, modifiedMs: result.modifiedMs)) } + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + let method: String + let params: [String: ToolingJSONValue] + switch mutation { + case .createDirectory(let path, let recursive): + method = "fs.createDir" + params = ["path": .string(path), "recursive": .bool(recursive)] + case .copy(let from, let to): + method = "fs.copy" + params = ["from": .string(from), "to": .string(to), "recursive": .bool(true)] + case .move(let from, let to): + method = "fs.rename" + params = ["from": .string(from), "to": .string(to)] + case .remove(let path): + method = "fs.remove" + params = ["path": .string(path), "recursive": .bool(true)] + case .trash(let path): + method = "fs.trash" + params = ["path": .string(path)] + } + let value: ToolingJSONValue = try blocking( + "remote.hostRequest", payload: HostPayload(sessionId: session, method: method, params: params) + ) + if case .object(let result) = value, + case .number(let skipped) = result["skippedSpecialFiles"], skipped > 0 { + throw RemoteProbeFailure(code: "copy_incomplete", message: L10n.string("Some special files could not be copied")) + } + } + + func watch( + root: String, session: String, + onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void + ) async throws { + struct Parameters: Encodable, Sendable { let root: String } + struct Payload: Encodable, Sendable { let sessionId: String; let params: Parameters } + struct Event: Decodable { + struct Parameters: Decodable { + struct Change: Decodable { let path: String } + let changes: [Change]? + let overflow: Bool? + let code: String? + } + let type: String + let event: String? + let params: Parameters? + } + let result: Result = await run( + "remote.runWatch", payload: Payload(sessionId: session, params: Parameters(root: root)), + onPrompt: { _ in }, onEvent: { raw in + guard let event = try? JSONDecoder().decode(Event.self, from: Data(raw.utf8)), + event.type == "remote" else { return } + switch event.event { + case "watch.started": onEvent(.started) + case "watch.changed": + onEvent(.changed(paths: event.params?.changes?.map(\.path) ?? [], overflow: event.params?.overflow ?? true)) + case "watch.error": onEvent(.invalidated(rootRemoved: event.params?.code == "ROOT_REMOVED")) + default: break + } + } + ) + _ = try result.get() + } + + func runTerminal( + _ request: RemoteTerminalRequest, session: String, + onEvent: @escaping @Sendable (RemoteTerminalEvent) -> Void + ) async -> Result { + struct Payload: Encodable, Sendable { let sessionId: String; let params: RemoteTerminalRequest } + struct Exit: Decodable, Sendable { let exitCode: Int32? } + struct Event: Decodable { + struct Parameters: Decodable { + let terminalId: UInt64? + let pid: Int32? + let data: String? + let exitCode: Int32? + } + let type: String + let event: String? + let params: Parameters? + } + let result: Result = await run( + "remote.runTerminal", payload: Payload(sessionId: session, params: request), + onPrompt: { _ in }, onEvent: { raw in + guard let event = try? JSONDecoder().decode(Event.self, from: Data(raw.utf8)), + event.type == "remote", let params = event.params else { return } + switch event.event { + case "terminal.started": + if let id = params.terminalId, let pid = params.pid { onEvent(.started(id: id, pid: pid)) } + case "terminal.output": + if let encoded = params.data, let data = Data(base64Encoded: encoded) { onEvent(.output(data)) } + case "terminal.exit": onEvent(.exited(params.exitCode)) + default: break + } + } + ) + return result.map { _ in () } + } + + func writeTerminal(_ data: Data, id: UInt64, session: String) async throws { + struct Parameters: Encodable, Sendable { let terminalId: UInt64; let data: String } + try await terminalControl("terminal.write", params: Parameters(terminalId: id, data: data.base64EncodedString()), session: session) + } + + func resizeTerminal(id: UInt64, columns: UInt16, rows: UInt16, session: String) async throws { + struct Parameters: Encodable, Sendable { let terminalId: UInt64; let columns: UInt16; let rows: UInt16 } + try await terminalControl("terminal.resize", params: Parameters(terminalId: id, columns: columns, rows: rows), session: session) + } + + private struct HostPayload: Encodable, Sendable { + let sessionId: String + let method: String + let params: Parameters + } + + private func terminalControl( + _ method: String, params: Parameters, session: String + ) async throws { + // Control methods return JSON null; the JSON value decoder preserves + // this successful response without requiring a synthetic data object. + let payload = HostPayload(sessionId: session, method: method, params: params) + let result: Result = await run( + "remote.hostRequest", payload: payload, onPrompt: { _ in } + ) + _ = try result.get() + } + private func blocking(_ command: String, payload: Payload) throws -> Value { let result: Result = core.executeResult(command: command, payload: payload) return try result.mapError { RemoteProbeFailure(code: $0.code, message: $0.message) }.get() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index f42646cc5b..6235423301 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -15,7 +15,9 @@ extension AppModel { @MainActor func activateTerminalModule() async -> Bool { - await terminalModuleCoordinator.activate() + let activated = await terminalModuleCoordinator.activate() + if activated { terminalFeature?.setShellWorkspace(workspaceURL) } + return activated } func toggleTerminal() { @@ -61,7 +63,8 @@ extension AppModel { } let session = feature.createSession( in: workspaceURL, - shellPath: shellPath ?? settings.terminalShellPath, + shellPath: shellPath ?? (services.remoteConnections?.workspaces.contains(workspaceURL) == true + ? nil : settings.terminalShellPath), preferredToolTabTitle: shellPath.map { URL(fileURLWithPath: $0).lastPathComponent }, showsWorkbenchBackground: workbenchBackgroundFeature.hasImage ) @@ -151,8 +154,9 @@ extension AppModel { TerminalEnvironmentChange(name: $0.name, value: $0.value) } ) - let created = try feature.createProcessSession( + let created = try await feature.createProcessSessionAsync( launch, + workspaceURL: workspaceURL, showsWorkbenchBackground: workbenchBackgroundFeature.hasImage ) { [weak self] output in self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output) @@ -165,7 +169,8 @@ extension AppModel { debugTerminalSessionIDsByDebugSession[debugSessionID, default: []].insert(created.session.id) activeDebugTerminalSessionIDsByDebugSession[debugSessionID] = created.session.id } - if let id = debugSessionID, executablePath.hasSuffix("/java") { + if let id = debugSessionID, executablePath.hasSuffix("/java"), + services.remoteConnections?.workspaces.contains(workspaceURL) != true { debugPerformanceFeature.register(DebugPerformanceTarget(processID: created.processID, javaExecutable: URL(fileURLWithPath: executablePath)), sessionID: id.rawValue) } @@ -400,7 +405,10 @@ extension AppModel { } var activeTerminalShellPath: String { - settings.terminalShellPath ?? terminalFeature?.availableShells.first ?? "/bin/zsh" + if let workspaceURL, services.remoteConnections?.workspaces.contains(workspaceURL) == true { + return terminalFeature?.availableShells.first ?? "" + } + return settings.terminalShellPath ?? terminalFeature?.availableShells.first ?? "/bin/zsh" } private func sessions(orderedBy sessionIDs: [UUID]) -> [TerminalSession] { diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index ddc23a34e8..d6e6a52f92 100644 --- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -311,6 +311,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable { case ready case jdkMissing case configuredJDKInvalid(path: String) + case probeFailed(message: String) var requiresAttention: Bool { self != .checking && self != .ready @@ -318,7 +319,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable { var blocksJavaRun: Bool { switch self { - case .jdkMissing, .configuredJDKInvalid: true + case .jdkMissing, .configuredJDKInvalid, .probeFailed: true case .checking, .ready: false } } @@ -345,6 +346,7 @@ struct JavaEnvironmentReport: Equatable, Sendable { case .ready: "Java environment ready" case .jdkMissing: "JDK not found" case .configuredJDKInvalid: "Configured JDK is invalid" + case .probeFailed: "Runtime discovery failed" } } @@ -358,6 +360,7 @@ struct JavaEnvironmentReport: Equatable, Sendable { "This project contains Java sources, but no usable JDK was detected." case .configuredJDKInvalid(let path): "The configured JDK path is not a valid JDK: \(path)" + case .probeFailed(let message): message } } @@ -368,6 +371,8 @@ struct JavaEnvironmentReport: Equatable, Sendable { "Choose a JDK in Settings → Project or install a full JDK and set JAVA_HOME." case .configuredJDKInvalid: "Choose another JDK in Settings → Project or clear the invalid path." + case .probeFailed: + "Check the workspace connection and refresh the available runtimes." } } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index a108325da3..5fe9fe05e8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -34,6 +34,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { private let versionChecker: any LanguageServerRuntimeVersionChecking private let runtime: any LanguageToolRuntimePort private let core: any LanguageServerRuntimeCore + private let remoteLaunches: MacRemotePluginLaunchPreparer? private weak var processRegistry: (any LanguageServerProcessRegistry)? /// Ends the processes carrying a backend's token; `immediately` skips the /// graceful stop. See `MacPluginBackendProcesses`. @@ -47,11 +48,13 @@ final class MacBuildProviderSessions: BuildProviderRequesting { let id = UUID() /// The plugin's setting values the launch was expanded with. let settings: [String: ToolingJSONValue] + let hostIdentity: String? var task: Task? var session: LanguageServerRuntimeSession? - init(settings: [String: ToolingJSONValue]) { + init(settings: [String: ToolingJSONValue], hostIdentity: String?) { self.settings = settings + self.hostIdentity = hostIdentity } } @@ -62,6 +65,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { runtime: any LanguageToolRuntimePort, core: any LanguageServerRuntimeCore, processRegistry: (any LanguageServerProcessRegistry)?, + remoteLaunches: MacRemotePluginLaunchPreparer? = nil, stuckSendGrace: Duration = .seconds(2), endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( @@ -75,6 +79,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { self.versionChecker = versionChecker self.runtime = runtime self.core = core + self.remoteLaunches = remoteLaunches self.processRegistry = processRegistry self.endBackendProcesses = endBackendProcesses self.stuckSendGrace = stuckSendGrace @@ -91,14 +96,10 @@ final class MacBuildProviderSessions: BuildProviderRequesting { if let session = try await runningSession(for: target, settings: settings) { return try await session.buildRequest(method: request.method, params: request.params, timeout: seconds) } - switch try launchResolver.resolveBuildProvider( - pluginID: target.pluginID, - buildSystemID: target.buildSystemID, - manifest: target.manifest, - packageURL: target.packageURL, - workspaceRootURL: target.workspaceURL - ) { + let prepared = try await prepareLaunch(target) + switch prepared.launch { case .languageServer(let languageID): + await prepared.owner?.release() guard let sessions = languageSessions?() else { throw UnavailableError(message: String( localized: "The language server this build system uses is not available. Open a file of the language and try again." @@ -113,7 +114,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { timeout: seconds ) case .process(let process): - let session = try await startSession(for: target, process: process) + let session = try await startSession(for: target, process: process, owner: prepared.owner) try Task.checkCancellation() return try await session.buildRequest(method: request.method, params: request.params, timeout: seconds) } @@ -144,20 +145,16 @@ final class MacBuildProviderSessions: BuildProviderRequesting { try Task.checkCancellation() let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) if let session = try await runningSession(for: target, settings: settings) { return session } - switch try launchResolver.resolveBuildProvider( - pluginID: target.pluginID, - buildSystemID: target.buildSystemID, - manifest: target.manifest, - packageURL: target.packageURL, - workspaceRootURL: target.workspaceURL - ) { + let prepared = try await prepareLaunch(target) + switch prepared.launch { case .languageServer: + await prepared.owner?.release() throw PluginFunctionFailure( code: "plugin.function.unavailable", message: L10n.string("This build system's provider runs in a shared language server, which serves no plugin functions.") ) case .process(let process): - let session = try await startSession(for: target, process: process) + let session = try await startSession(for: target, process: process, owner: prepared.owner) try Task.checkCancellation() return session } @@ -191,7 +188,7 @@ final class MacBuildProviderSessions: BuildProviderRequesting { settings: [String: ToolingJSONValue] ) async throws -> LanguageServerRuntimeSession? { guard let entry = running[target], let task = entry.task else { return nil } - if entry.settings != settings { + if entry.settings != settings || entry.hostIdentity != remoteLaunches?.identity(target.workspaceURL) { stop(entry, for: target) return nil } @@ -206,22 +203,33 @@ final class MacBuildProviderSessions: BuildProviderRequesting { private func startSession( for target: BuildProviderTarget, - process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess + process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, + owner: MacRemotePluginLaunchPreparer.Owner? ) async throws -> LanguageServerRuntimeSession { // Another request may have started the process while this one // resolved the launch. - if let session = try await runningSession(for: target, settings: process.settings) { return session } - try Task.checkCancellation() - let entry = Running(settings: process.settings) + do { + if let session = try await runningSession(for: target, settings: process.settings) { + await owner?.release() + return session + } + try Task.checkCancellation() + if let owner, !owner.isCurrent() { throw CancellationError() } + } catch { await owner?.release(); throw error } + let entry = Running(settings: process.settings, hostIdentity: remoteLaunches?.identity(target.workspaceURL)) running[target] = entry // The start is shared by every request that arrives meanwhile, so no // single caller's cancellation cancels it; a failed start clears the // entry itself. let task = Task { @MainActor [weak self] () throws -> LanguageServerRuntimeSession in - guard let self else { throw CancellationError() } + guard let self else { + await owner?.release() + throw CancellationError() + } do { - return try await self.launch(process, for: target, entry: entry) + return try await self.launch(process, for: target, entry: entry, owner: owner) } catch { + if entry.session == nil { await owner?.release() } if self.running[target] === entry { self.running[target] = nil } throw error } @@ -269,15 +277,16 @@ final class MacBuildProviderSessions: BuildProviderRequesting { private func launch( _ process: MacPluginLanguageServerLaunchResolver.BuildProviderProcess, for target: BuildProviderTarget, - entry: Running + entry: Running, + owner: MacRemotePluginLaunchPreparer.Owner? ) async throws -> LanguageServerRuntimeSession { var (executableURL, environment) = try await resolveExecutable(process, workspaceURL: target.workspaceURL) try Task.checkCancellation() - guard running[target] === entry else { throw CancellationError() } + guard running[target] === entry, owner?.isCurrent() != false else { throw CancellationError() } // Every process the backend starts inherits this token, so what it // leaves behind can be ended when the backend is gone. let backendToken = UUID().uuidString - environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + if owner == nil { environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } } let session = LanguageServerRuntimeSession( providerID: "buildProvider:\(target.pluginID):\(target.buildSystemID)", executableURL: executableURL, @@ -285,17 +294,21 @@ final class MacBuildProviderSessions: BuildProviderRequesting { environment: environment, initializationOptions: process.initializationOptions, initializeTimeout: 60, - core: core, + core: owner?.core ?? core, processRegistry: processRegistry, moduleID: .buildSystemsExtension(target.pluginID), pluginID: target.pluginID ) let endBackendProcesses = endBackendProcesses session.stuckSendGrace = stuckSendGrace - session.terminateProcess = { endBackendProcesses(backendToken, true) } - session.onReleased = { endBackendProcesses(backendToken, false) } + if let owner { session.releaseResources = owner.release } + else { + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + } entry.session = session - try await waitUntilReady(session, rootURL: target.workspaceURL) + do { try await waitUntilReady(session, rootURL: target.workspaceURL) } + catch { session.stop(); throw error } // A queued ready event can race a stop while initialize is suspended. try Task.checkCancellation() guard running[target] === entry else { throw CancellationError() } @@ -351,7 +364,10 @@ final class MacBuildProviderSessions: BuildProviderRequesting { var requirement: LanguageServerExecutableVersionRequirement? switch process.executable { case .file(let url): - return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + if remoteLaunches?.identity(workspaceURL) == nil { + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + } + invocation = BuildInvocation(executables: [.setting(id: "pluginPackage", path: url.path)], runtimeDependencies: runtimes) case .commands(let commands): invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) case .runtime(let tool, let versionRequirement): @@ -380,6 +396,14 @@ final class MacBuildProviderSessions: BuildProviderRequesting { return (resolved.executableURL, resolved.environment) } + private func prepareLaunch(_ target: BuildProviderTarget) async throws -> ( + launch: MacPluginLanguageServerLaunchResolver.BuildProviderLaunch, owner: MacRemotePluginLaunchPreparer.Owner? + ) { + if let prepared = try await remoteLaunches?.prepareBuild(target) { return (prepared.launch, prepared.owner) } + return (try launchResolver.resolveBuildProvider(pluginID: target.pluginID, buildSystemID: target.buildSystemID, + manifest: target.manifest, packageURL: target.packageURL, workspaceRootURL: target.workspaceURL), nil) + } + private static func seconds(_ duration: Duration) -> TimeInterval { let components = duration.components return TimeInterval(components.seconds) + TimeInterval(components.attoseconds) / 1e18 diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift index d3d5e9021b..9dccbb191c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift @@ -18,14 +18,22 @@ final class MacBuildToolResolver: BuildToolResolving { private let runtime: ProjectRuntimeService private let processRunner: any ProcessRunner private let fileStorage: any FileStorage + private let registry: RemoteWorkspaceRegistry? + private let remoteFiles: (any RemoteBuildFileProbing)? + private let remoteProcesses: (any RemoteProcessRunning)? /// Probe output by executable, arguments, and modification date, so a /// repeated run does not start the same probe again. private var probeOutputs: [String: String] = [:] - init(runtime: ProjectRuntimeService, processRunner: any ProcessRunner, fileStorage: any FileStorage) { + init(runtime: ProjectRuntimeService, processRunner: any ProcessRunner, fileStorage: any FileStorage, + registry: RemoteWorkspaceRegistry? = nil, remoteFiles: (any RemoteBuildFileProbing)? = nil, + remoteProcesses: (any RemoteProcessRunning)? = nil) { self.runtime = runtime self.processRunner = processRunner self.fileStorage = fileStorage + self.registry = registry + self.remoteFiles = remoteFiles + self.remoteProcesses = remoteProcesses } func resolve( @@ -34,6 +42,11 @@ final class MacBuildToolResolver: BuildToolResolving { workspaceURL: URL, checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck ) async throws -> ResolvedBuildTool { + if registry?.contains(workspaceURL) == true { + await runtime.ensureRuntimesDiscovered() + try Task.checkCancellation() + guard runtime.projectURL == workspaceURL.standardizedFileURL else { throw CancellationError() } + } var environment = runtime.processEnvironment() var runtimeExecutables: [String: URL] = [:] for dependency in invocation.runtimeDependencies { @@ -47,7 +60,8 @@ final class MacBuildToolResolver: BuildToolResolving { } var problems: [String] = [] - let resolver = candidateResolver(runtimeExecutables) + let resolver = try await preparedResolver(runtimeExecutables, candidates: invocation.executables, + workspaceURL: workspaceURL, environment: environment) for candidate in invocation.executables { switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { case .skip: continue @@ -63,7 +77,7 @@ final class MacBuildToolResolver: BuildToolResolving { } do { if let tool = try await accept(choice.url, invocation: invocation, environment: selectedEnvironment, - checkVersion: checkVersion, problems: &problems) { return tool } + workspaceURL: workspaceURL, checkVersion: checkVersion, problems: &problems) { return tool } } catch is CancellationError { throw CancellationError() } catch { throw BuildToolResolutionError(error.localizedDescription, terminal: choice.terminal) } if choice.terminal { throw BuildToolResolutionError(Self.unavailableMessage(displayName, problems: problems), terminal: true) } @@ -76,6 +90,7 @@ final class MacBuildToolResolver: BuildToolResolving { /// Project import needs the installation path for global settings, but /// must not run build code or a version probe merely by opening a project. func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? { + guard registry?.contains(workspaceURL) != true else { return nil } let environment = runtime.processEnvironment() let resolver = candidateResolver([:]) for candidate in invocation.executables { @@ -89,6 +104,75 @@ final class MacBuildToolResolver: BuildToolResolving { return nil } + func prepareLanguageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) async throws -> URL? { + guard registry?.contains(workspaceURL) == true else { + return languageImportExecutable(invocation, workspaceURL: workspaceURL) + } + try Task.checkCancellation() + guard runtime.projectURL == workspaceURL.standardizedFileURL else { throw CancellationError() } + let environment = runtime.processEnvironment() + let resolver = try await preparedResolver([:], candidates: invocation.executables, + workspaceURL: workspaceURL, environment: environment) + for candidate in invocation.executables { + switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, + environmentInputs: invocation.environmentInputs) { + case .skip: continue + case .terminalFailure: return nil + case .resolved(let choices): return choices.first?.url + } + } + return nil + } + + private func preparedResolver( + _ runtimes: [String: URL], candidates: [ExecutableCandidate], workspaceURL: URL, + environment: [String: String] + ) async throws -> ExecutableCandidateResolver { + guard let binding = registry?.resolve(workspaceURL)?.binding else { return candidateResolver(runtimes) } + guard let remoteFiles else { throw CocoaError(.featureUnsupported) } + var paths = Set() + var commands: [String: [URL]] = [:] + for candidate in candidates { + switch candidate { + case .projectFile(let path, let requires): + paths.insert(workspaceURL.appendingPathComponent(path).standardizedFileURL) + requires.forEach { paths.insert(workspaceURL.appendingPathComponent($0).standardizedFileURL) } + case .environment(let variable, let path): + if let base = environment[variable], base.hasPrefix("/") { + paths.insert(URL(fileURLWithPath: base).appendingPathComponent(path).standardizedFileURL) + } + case .command(let command): + let found = commandCandidates(command) + commands[command] = found + paths.formUnion(found) + case .runtime(let id): if let url = runtimes[id] { paths.insert(url) } + case .setting(_, let path): if let path, path.hasPrefix("/") { paths.insert(URL(fileURLWithPath: path)) } + } + } + let requested = paths.sorted { $0.path < $1.path }.map { url in + let root = binding.root.path + let path = url.path == root ? binding.remoteRoot : url.path.hasPrefix(root + "/") + ? binding.remoteRoot + url.path.dropFirst(root.count) : url.path + return (url, path) + } + let files: [URL: RemoteBuildFileMetadata] = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { + var files: [URL: RemoteBuildFileMetadata] = [:] + for (url, path) in requested { + if let metadata = try remoteFiles.buildFileMetadata(path, session: binding.sessionID) { + files[url] = metadata + } + } + return files + }) + } + } + try Task.checkCancellation() + return ExecutableCandidateResolver(fileExists: { files[$0] != nil }, + isExecutable: { files[$0]?.isExecutable == true }, commands: { commands[$0] ?? [] }, runtimes: { runtimes[$0] }) + } + private func candidateResolver(_ runtimes: [String: URL]) -> ExecutableCandidateResolver { ExecutableCandidateResolver(fileExists: { [fileStorage] in fileStorage.fileExists(at: $0) }, isExecutable: { [fileStorage] in fileStorage.metadata(for: $0)?.isDirectory == false && fileStorage.isExecutable(at: $0) }, @@ -101,13 +185,14 @@ final class MacBuildToolResolver: BuildToolResolving { _ url: URL, invocation: BuildInvocation, environment: [String: String], + workspaceURL: URL, checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck, problems: inout [String] ) async throws -> ResolvedBuildTool? { guard let arguments = invocation.versionArguments, let minimum = invocation.minimumVersion else { return ResolvedBuildTool(executableURL: url, environment: environment, executesProjectCode: false, version: nil) } - let output = await probe(url, arguments: arguments, environment: environment) + let output = try await probe(url, arguments: arguments, environment: environment, workspaceURL: workspaceURL) let check = try checkVersion(nil, output) guard check.satisfied else { problems.append(Self.tooOldMessage(url.path, found: check.version, minimum: minimum)) @@ -131,7 +216,8 @@ final class MacBuildToolResolver: BuildToolResolving { let sources: [ExecutableCandidate] = dependency.hostManaged ? (dependency.id == "java" ? [.runtime("java")] : [.command(dependency.id)]) : dependency.executables - let resolver = candidateResolver(javaHome.map { ["java": $0.appendingPathComponent("bin/java")] } ?? [:]) + let resolver = try await preparedResolver(javaHome.map { ["java": $0.appendingPathComponent("bin/java")] } ?? [:], + candidates: sources, workspaceURL: workspaceURL, environment: environment) var problems: [String] = [] for source in sources { switch resolver.resolve(source, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { @@ -149,7 +235,7 @@ final class MacBuildToolResolver: BuildToolResolving { selectedEnvironment["PATH"] = path.isEmpty ? directory : directory + ":" + path if !choice.executesProjectCode, let minimum = dependency.minimumVersion, let arguments = dependency.versionArguments { - let output = await probe(choice.url, arguments: arguments, environment: selectedEnvironment) + let output = try await probe(choice.url, arguments: arguments, environment: selectedEnvironment, workspaceURL: workspaceURL) let check: BuildVersionCheck do { check = try checkVersion(dependency.id, output) } catch { throw BuildToolResolutionError(error.localizedDescription, terminal: choice.terminal) } @@ -173,7 +259,18 @@ final class MacBuildToolResolver: BuildToolResolving { .map(\.executableURL) } - private func probe(_ url: URL, arguments: [String], environment: [String: String]) async -> String { + private func probe(_ url: URL, arguments: [String], environment: [String: String], workspaceURL: URL) async throws -> String { + if let registry, registry.contains(workspaceURL) { + guard let remoteProcesses else { throw CocoaError(.featureUnsupported) } + let runner = MacRemoteBuildCommandRunner(local: MacBuildCommandRunner(), registry: registry, host: remoteProcesses) + let captured = RemoteBuildProbeOutput() + let result = try await runner.run(BuildCommandRequest(executableURL: url, arguments: arguments, + workingDirectory: workspaceURL, environment: environment, timeout: .milliseconds(Self.probeTimeoutMilliseconds))) { + captured.append($0) + } + guard case .exited(0) = result.termination else { return "" } + return try captured.value() + } let modified = fileStorage.metadata(for: url)?.modificationDate?.timeIntervalSince1970 ?? 0 let key = ([url.path, String(modified)] + arguments).joined(separator: "\u{1F}") if let output = probeOutputs[key] { return output } @@ -202,6 +299,29 @@ final class MacBuildToolResolver: BuildToolResolving { } } +private final class RemoteBuildProbeOutput: @unchecked Sendable { + private let lock = NSLock() + private var output = "" + private var overflowed = false + func append(_ text: String) { + lock.withLock { + guard !overflowed else { return } + guard output.utf8.count + text.utf8.count <= 262_144 else { + overflowed = true + output = "" + return + } + output += text + } + } + func value() throws -> String { + try lock.withLock { + guard !overflowed else { throw CocoaError(.fileReadTooLarge) } + return output + } + } +} + /// Stored option values. They follow the build-system ID, as `build.detect` /// takes them, so a plugin update keeps the user's choices. @MainActor diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 752fcdec28..64e6f173e6 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -14,12 +14,21 @@ import LitheTerminalModule import LitheWorkspaceModule private struct MacDirectoryWatcherFactory: DirectoryWatcherFactory { + let registry: RemoteWorkspaceRegistry + let transport: any RemoteWorkspaceTransport + func make( configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void ) -> any DirectoryChangeSource { - MacDirectoryWatcher( + if let binding = registry.resolve(configuration.workspaceRoot)?.binding { + return RemoteDirectoryObservation( + configuration: configuration, visibilityRules: visibilityRules, + binding: binding, registry: registry, transport: transport, onChange: onChange + ) + } + return MacDirectoryWatcher( configuration: configuration, visibilityRules: visibilityRules, onChange: onChange @@ -89,8 +98,10 @@ final class MacServiceContainer { fields: ["operation_id": event.operationId, "duration_ms": event.durationMilliseconds.map(String.init) ?? "", "exit_code": event.exitCode.map(String.init) ?? "", "error_code": event.error?.code ?? ""]) } + let remoteWorkspaces = RemoteWorkspaceRegistry() let rustCore = RustCoreBridge(gitPreferences: settings.gitExecutionPreferences, - gitExecutionJournal: gitExecutionJournal) + gitExecutionJournal: gitExecutionJournal, + remoteWorkspaces: remoteWorkspaces) let mavenRepositoryURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".m2/repository", isDirectory: true) let gradleRepositoryURL = FileManager.default.homeDirectoryForCurrentUser @@ -112,7 +123,6 @@ final class MacServiceContainer { let debugSteppingFilterStore = MacDebugSteppingFilterStore(store: store) // Remote workspaces open under an empty placeholder root; both file // ports route every URL below such a root to the connected host. - let remoteWorkspaces = RemoteWorkspaceRegistry() let remoteOperations = RustRemoteConnectionOperations(core: rustCore) // Core owns the OpenSSH trees; closing them at exit also stops the // remote servers they carry. @@ -244,6 +254,23 @@ final class MacServiceContainer { debugAdapterSessionsBox.stopSessions(ofPlugin: pluginID) } } + let pluginMarketplaceEvaluator = RustPluginMarketplaceOperations(core: rustCore) + let pluginMarketplaceTransport: any MacPluginMarketplaceTransport + #if DEBUG + pluginMarketplaceTransport = MacPluginMarketplaceFixtureTransport.fromEnvironment() + ?? MacURLSessionPluginMarketplaceTransport() + #else + pluginMarketplaceTransport = MacURLSessionPluginMarketplaceTransport() + #endif + let remotePluginPackages = MacRemotePluginPackageDeployer( + installed: pluginStartup.registeredPlugins, + makeStore: { architecture in + MacPluginPackageStore(rootURL: fileStorage.applicationSupportDirectory().appendingPathComponent("Lithe/Plugins"), + validator: declarativePluginOperations, architecture: architecture) + }, + evaluator: pluginMarketplaceEvaluator, transport: pluginMarketplaceTransport, + host: remoteOperations, temporaryDirectory: fileStorage.temporaryDirectory() + ) let bundledLanguageManifests = BundledLanguagePluginCatalog.manifests let moduleRegistry = ModuleRegistry( runtime: moduleRuntime, @@ -286,7 +313,20 @@ final class MacServiceContainer { preferredAppearanceIsDark: { settings.themePreference.preferredIsDark } ) }, - shellDiscovery: { MacTerminalTransport.availableShells() } + workspaceTerminalFactory: { root in + let renderer = MacTerminalTransport( + scrollbackLines: settings.terminalScrollbackLines, + preferredAppearanceIsDark: { settings.themePreference.preferredIsDark } + ) + guard let resolved = remoteWorkspaces.resolve(root) else { return renderer } + return MacRemoteTerminalTransport(renderer: renderer, binding: resolved.binding, + transport: remoteOperations) + }, + shellDiscovery: { MacTerminalTransport.availableShells() }, + workspaceShellDiscovery: { root in + guard let resolved = remoteWorkspaces.resolve(root) else { return nil } + return try await MacRemoteShellDiscovery.discover(session: resolved.binding.sessionID, host: remoteOperations) + } ) }) } catch { @@ -295,7 +335,13 @@ final class MacServiceContainer { let runtimeService = ProjectRuntimeService( runtimeLocator: MacRuntimeLocator(), store: store, - toolDiscovery: MacRuntimeToolDiscovery() + toolDiscovery: MacRuntimeToolDiscovery(), + javaSelector: rustCore, + workspaceRuntime: { root in + guard let binding = remoteWorkspaces.binding(forRoot: root) else { return nil } + let locator = MacRemoteRuntimeLocator(binding: binding, host: remoteOperations) + return (locator, locator) + } ) let languageServerCacheDirectory = fileStorage.cacheDirectory() .appendingPathComponent("Lithe/language-servers", isDirectory: true) @@ -319,23 +365,29 @@ final class MacServiceContainer { // Provider processes of plugin build systems, one per plugin, build // system, and workspace. Created eagerly so the language sessions // can be connected; nothing starts until a model or dependency load. + let remotePluginLaunches = MacRemotePluginLaunchPreparer(registry: remoteWorkspaces, packages: remotePluginPackages, + host: remoteOperations, planner: declarativePluginOperations, resolver: pluginLaunchResolver, core: rustCore) + let providerTools = MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage, + registry: remoteWorkspaces, remoteFiles: remoteOperations, remoteProcesses: remoteOperations) let buildProviderSessions = MacBuildProviderSessions( launchResolver: pluginLaunchResolver, - tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + tools: providerTools, versionChecker: declarativePluginOperations, runtime: runtimeService, core: rustCore, - processRegistry: processRegistry + processRegistry: processRegistry, + remoteLaunches: remotePluginLaunches ) // Provider processes of plugin VCS providers, one per plugin, VCS, // and workspace; one starts on the first request for a detected root. let vcsProviderSessions = MacVcsProviderSessions( launchResolver: pluginLaunchResolver, - tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage), + tools: providerTools, versionChecker: declarativePluginOperations, runtime: runtimeService, core: rustCore, - processRegistry: processRegistry + processRegistry: processRegistry, + remoteLaunches: remotePluginLaunches ) // Plugin backends, one per plugin for the whole application: the // first project window creates them, and every window registers the @@ -432,6 +484,13 @@ final class MacServiceContainer { try pluginLaunchResolver.resolve(source, workspaceRootURL: rootURL) }, languageServerCacheDirectory: languageServerCacheDirectory, + workspaceSessionFactory: { descriptor, root in + LanguageServerRuntimeSession(providerID: descriptor.launchSource?.languageID ?? "remote", + executableURL: root, arguments: [], environment: [:], prepareProcess: { workspace in + try await remotePluginLaunches.prepareLanguage(descriptor, workspaceURL: workspace, + tools: providerTools, versionChecker: declarativePluginOperations) + }, core: rustCore, processRegistry: processRegistry, pluginID: descriptor.launchSource?.pluginID) + }, processRegistry: processRegistry ) let runtimes = languagePackDefinitions.packs @@ -456,12 +515,14 @@ final class MacServiceContainer { .contains(providerID) }, workspaceFingerprintProvider: { descriptor, workspaceRootURL in - try languageServerHostIntegrations.workspaceFingerprint( + if remoteWorkspaces.contains(workspaceRootURL) { return nil } + return try languageServerHostIntegrations.workspaceFingerprint( for: descriptor, workspaceRootURL: workspaceRootURL ) }, workspaceStateResetter: { descriptor, workspaceRootURL, fingerprint in + guard !remoteWorkspaces.contains(workspaceRootURL) else { throw CocoaError(.featureUnsupported) } try languageServerHostIntegrations.resetWorkspaceState( for: descriptor, workspaceRootURL: workspaceRootURL, @@ -469,7 +530,8 @@ final class MacServiceContainer { ) }, workspaceStateCleaner: { descriptor, workspaceRootURL, fingerprint in - try languageServerHostIntegrations.pruneWorkspaceState( + if remoteWorkspaces.contains(workspaceRootURL) { return 0 } + return try languageServerHostIntegrations.pruneWorkspaceState( for: descriptor, workspaceRootURL: workspaceRootURL, fingerprint: fingerprint @@ -506,14 +568,19 @@ final class MacServiceContainer { tools: MacBuildToolResolver( runtime: runtimeService, processRunner: processRunner, - fileStorage: fileStorage + fileStorage: fileStorage, + registry: remoteWorkspaces, + remoteFiles: remoteOperations, + remoteProcesses: remoteOperations ), optionsStore: MacBuildOptionsStore(store: store), - stateLocator: MacBuildSystemStateLocator( + stateLocator: MacRemoteBuildStateLocator(local: MacBuildSystemStateLocator( directories: MacPluginStateDirectories(fileStorage: fileStorage), fileStorage: fileStorage - ), - runner: MacBuildCommandRunner(processRegistry: processRegistry), + ), registry: remoteWorkspaces, host: remoteOperations, packages: remotePluginPackages), + runner: MacRemoteBuildCommandRunner( + local: MacBuildCommandRunner(processRegistry: processRegistry), + registry: remoteWorkspaces, host: remoteOperations), providers: buildProviderSessions, settings: { pluginSettings.values(forPlugin: $0) }, sources: { @@ -763,7 +830,6 @@ final class MacServiceContainer { chooseFile: MacPluginWebFileDialog.choose ) pluginWebPageLifecycle.register(pluginWebPages) - let pluginMarketplaceEvaluator = RustPluginMarketplaceOperations(core: rustCore) let pluginManager = MacPluginManager( packageStore: pluginPackageStore, moduleRuntime: moduleRuntime, @@ -815,13 +881,6 @@ final class MacServiceContainer { }, livePlugins: livePlugins ) - let pluginMarketplaceTransport: any MacPluginMarketplaceTransport - #if DEBUG - pluginMarketplaceTransport = MacPluginMarketplaceFixtureTransport.fromEnvironment() - ?? MacURLSessionPluginMarketplaceTransport() - #else - pluginMarketplaceTransport = MacURLSessionPluginMarketplaceTransport() - #endif let pluginMarketplace = MacPluginMarketplaceService( evaluator: pluginMarketplaceEvaluator, packageStore: pluginPackageStore, @@ -933,7 +992,7 @@ final class MacServiceContainer { directoryMarkStore: directoryMarkStore, workbenchLayoutStore: WorkbenchLayoutStore(store: store), workbenchBackgroundPlatform: MacWorkbenchBackgroundPlatform(store: store), - directoryWatcherFactory: MacDirectoryWatcherFactory(), + directoryWatcherFactory: MacDirectoryWatcherFactory(registry: remoteWorkspaces, transport: remoteTransport), platformUI: platformUI, shortcutDetectorFactory: MacShortcutDetectorFactory(), debugPerformanceSampler: MacDebugPerformanceSampler(processRegistry: processRegistry), @@ -960,7 +1019,7 @@ final class MacServiceContainer { mappingStore: MacVcsRootMappingStore(store: store), readFile: { url in try? fileStorage.readData(from: url, options: .mappedIfSafe) }, fileExists: { url in fileStorage.fileExists(at: url) }, - metadataWatcherFactory: MacDirectoryWatcherFactory(), + metadataWatcherFactory: MacDirectoryWatcherFactory(registry: remoteWorkspaces, transport: remoteTransport), sources: { // Enablement is read on every detection, so turning a // plugin off takes its roots away at once. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift index 83020f7bde..89d1ce2b33 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift @@ -140,6 +140,14 @@ struct MacPluginLanguageServerLaunchResolver { return values } + func globalSettingValues(forPlugin pluginID: String) -> [String: ToolingJSONValue] { + settings.values(forPlugin: pluginID) + } + + func projectSettingValues(forPlugin pluginID: String, workspaceRootURL: URL) -> [String: ToolingJSONValue] { + settings.projectValues(forPlugin: pluginID, projectRoot: workspaceRootURL) + } + static var hostArchitecture: String { #if arch(arm64) "arm64" diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 97e5d84072..865d4c4273 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -88,7 +88,7 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { } } -struct InstalledPluginPackage: Equatable { +struct InstalledPluginPackage: Equatable, Sendable { let descriptor: DeclarativePluginDescriptor let installation: PluginInstallationRecord let packageURL: URL @@ -127,6 +127,7 @@ final class MacPluginPackageStore { /// Expands build provider launches so installation can check the files /// they point at. A package that declares a provider is refused without it. private let launchPlanner: (any DeclarativeLanguageServerLaunchPlanning)? + private let architecture: String private let verifier: any PluginPackageSignatureVerifying private let fileManager: FileManager private let encoder: JSONEncoder @@ -140,6 +141,7 @@ final class MacPluginPackageStore { hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion, validator: any DeclarativePluginManifestValidating, launchPlanner: (any DeclarativeLanguageServerLaunchPlanning)? = nil, + architecture: String = MacPluginLanguageServerLaunchResolver.hostArchitecture, verifier: any PluginPackageSignatureVerifying = MacOfficialPluginSignatureVerifier(), fileManager: FileManager = .default ) { @@ -150,6 +152,7 @@ final class MacPluginPackageStore { self.hostVersion = hostVersion self.validator = validator self.launchPlanner = launchPlanner ?? validator as? any DeclarativeLanguageServerLaunchPlanning + self.architecture = architecture self.verifier = verifier self.fileManager = fileManager encoder = JSONEncoder() @@ -349,16 +352,20 @@ final class MacPluginPackageStore { /// A preparation worker owns its decoder and never reads or writes installed /// records. Immutable validation policy is shared with the installation store. - func makePreparationStoreFactory() -> @Sendable () -> MacPluginPackageStore { + func makePreparationStoreFactory(architecture: String? = nil) -> @Sendable () -> MacPluginPackageStore { let rootURL = rootURL let hostVersion = hostVersion let validator = validator let verifier = verifier + let launchPlanner = launchPlanner + let architecture = architecture ?? self.architecture return { MacPluginPackageStore( rootURL: rootURL, hostVersion: hostVersion, validator: validator, + launchPlanner: launchPlanner, + architecture: architecture, verifier: verifier, fileManager: FileManager() ) @@ -768,7 +775,7 @@ final class MacPluginPackageStore { pluginDirectory: root, stateDirectory: unused.appendingPathComponent("state", isDirectory: true), workspaceStateDirectory: nil, - architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + architecture: architecture, settings: [:] ) ) @@ -802,7 +809,7 @@ final class MacPluginPackageStore { pluginDirectory: root, stateDirectory: unused.appendingPathComponent("state", isDirectory: true), workspaceStateDirectory: unused.appendingPathComponent("workspace", isDirectory: true), - architecture: MacPluginLanguageServerLaunchResolver.hostArchitecture, + architecture: architecture, settings: [:] ) ) diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift new file mode 100644 index 0000000000..1966e088d8 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift @@ -0,0 +1,85 @@ +import Foundation +import LitheCoreContracts + +/// Keeps build orchestration and decoders intact, selecting process I/O from +/// the command's working directory rather than the currently displayed project. +struct MacRemoteBuildCommandRunner: BuildCommandRunning { + let local: any BuildCommandRunning + let registry: RemoteWorkspaceRegistry + let host: any RemoteProcessRunning + + func run( + _ request: BuildCommandRequest, + onOutput: @escaping @Sendable (String) -> Void + ) async throws -> BuildCommandResult { + guard let resolved = registry.resolve(request.workingDirectory) else { + return try await local.run(request, onOutput: onOutput) + } + let executable = registry.resolve(request.executableURL) + if let executable, executable.binding.sessionID != resolved.binding.sessionID { + throw CocoaError(.featureUnsupported) + } + let capture = RemoteBuildOutput(capturesStandardOutput: request.capturesStandardOutput, onOutput: onOutput) + defer { capture.finish() } + do { + let result = try await host.runProcess( + RemoteProcessRequest(argv: [executable?.remotePath ?? request.executableURL.path] + request.arguments, + cwd: resolved.remotePath, env: request.environment), + session: resolved.binding.sessionID, timeout: request.timeout, + onEvent: capture.receive + ) + capture.finish() + let status = result.exitCode ?? (128 + (result.signal ?? 1)) + return BuildCommandResult(termination: .exited(status), standardOutput: capture.standardOutput) + } catch let failure as RemoteProbeFailure where failure.code == "timed_out" { + return BuildCommandResult(termination: .timedOut, standardOutput: capture.standardOutput) + } + } +} + +private final class RemoteBuildOutput: @unchecked Sendable { + private let lock = NSLock() + private let captures: Bool + private let onOutput: @Sendable (String) -> Void + private var stdout = Data() + private var overflowed = false + private var stdoutDecoder = StreamingUTF8Decoder() + private var stderrDecoder = StreamingUTF8Decoder() + private var finished = false + + init(capturesStandardOutput: Bool, onOutput: @escaping @Sendable (String) -> Void) { + captures = capturesStandardOutput + self.onOutput = onOutput + } + + func receive(_ event: RemoteProcessEvent) { + lock.withLock { + guard !finished else { return } + if case .output(let bytes, let standardError) = event { + if captures && !standardError { + guard !overflowed else { return } + if stdout.count + bytes.count > MacBuildCommandRunner.standardOutputLimit { + overflowed = true + stdout.removeAll() + } else { stdout.append(bytes) } + } else { + let text = standardError ? stderrDecoder.decode(bytes) : stdoutDecoder.decode(bytes) + if !text.isEmpty { onOutput(text) } + } + } + } + } + + func finish() { + lock.withLock { + guard !finished else { return } + finished = true + let text = stdoutDecoder.finish() + stderrDecoder.finish() + if !text.isEmpty { onOutput(text) } + } + } + + var standardOutput: String? { + lock.withLock { captures && !overflowed ? String(data: stdout, encoding: .utf8) : nil } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift new file mode 100644 index 0000000000..df10f5f1f9 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildStateLocator.swift @@ -0,0 +1,68 @@ +import Foundation +import LitheCoreContracts + +/// Keeps run resources on the workspace's host; cleanup captures the original +/// session so reconnecting the workspace cannot redirect an old run's deletion. +struct MacRemoteBuildStateLocator: BuildSystemStateLocating { + let local: any BuildSystemStateLocating + let registry: RemoteWorkspaceRegistry + let host: any RemoteBuildStateAccess + var packages: (any RemotePluginPackageDeploying)? = nil + + func workspaceStateDirectory(pluginID: String, buildSystemID: String, workspaceURL: URL) throws -> URL { + guard !registry.contains(workspaceURL) else { throw CocoaError(.featureUnsupported) } + return try local.workspaceStateDirectory(pluginID: pluginID, buildSystemID: buildSystemID, workspaceURL: workspaceURL) + } + + func allocateOutputFile(in directory: URL) throws -> URL { + try local.allocateOutputFile(in: directory) + } + + func removeOutputFile(_ url: URL) { local.removeOutputFile(url) } + + func prepareRun(pluginID: String, buildSystemID: String, packageURL: URL?, + workspaceURL: URL, allocatesOutputFile: Bool) async throws -> BuildSystemRunResources { + guard let resolved = registry.resolve(workspaceURL) else { + return try await local.prepareRun(pluginID: pluginID, buildSystemID: buildSystemID, packageURL: packageURL, + workspaceURL: workspaceURL, allocatesOutputFile: allocatesOutputFile) + } + try Task.checkCancellation() + let session = resolved.binding.sessionID + let deployed: RemoteDeployedPluginPackage? + if let packageURL, let packages, + try packages.buildRequiresPackage(pluginID: pluginID, buildSystemID: buildSystemID, packageURL: packageURL) { + deployed = try await packages.deploy(pluginID: pluginID, packageURL: packageURL, session: session) + } else { deployed = nil } + let directory: String + do { + try Task.checkCancellation() + guard registry.binding(forRoot: resolved.binding.root)?.sessionID == session else { throw CancellationError() } + directory = try await Self.worker { + try host.prepareBuildState(pluginID: pluginID, buildSystemID: buildSystemID, + workspacePath: resolved.remotePath, session: session) + } + try Task.checkCancellation() + guard directory.hasPrefix("/"), !directory.contains("\0") else { throw CocoaError(.fileReadCorruptFile) } + } catch { + await deployed?.release() + throw error + } + let state = URL(fileURLWithPath: directory, isDirectory: true) + let output = allocatesOutputFile ? state.appendingPathComponent(UUID().uuidString + ".json") : nil + return BuildSystemRunResources(pluginDirectory: deployed?.packageURL, workspaceStateDirectory: state, outputFile: output) { + if let output { + do { try await Self.worker { try host.removeBuildOutputFile(output.path, session: session) } } + catch { NSLog("Remote build output cleanup failed (%@)", String(describing: type(of: error))) } + } + await deployed?.release() + } + } + + private static func worker(_ operation: @escaping @Sendable () throws -> Value) async throws -> Value { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { try operation() }) + } + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift new file mode 100644 index 0000000000..ffc1216945 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift @@ -0,0 +1,198 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule + +/// Resolves provider declarations only after the approved target package is +/// present. Every returned launch and cleanup retains the original connection. +@MainActor +final class MacRemotePluginLaunchPreparer { + struct Build { + let launch: MacPluginLanguageServerLaunchResolver.BuildProviderLaunch + let owner: Owner + } + + struct Vcs { + let launch: MacPluginLanguageServerLaunchResolver.VcsProviderProcess + let owner: Owner + } + + struct Owner { + let core: any LanguageServerRuntimeCore + let release: @Sendable () async -> Void + let isCurrent: @MainActor () -> Bool + } + + let registry: RemoteWorkspaceRegistry + let packages: any RemotePluginPackageDeploying + let host: any RemotePluginFileAccess + let planner: any DeclarativeLanguageServerLaunchPlanning + let resolver: MacPluginLanguageServerLaunchResolver + let core: RustCoreBridge + + init(registry: RemoteWorkspaceRegistry, packages: any RemotePluginPackageDeploying, + host: any RemotePluginFileAccess, planner: any DeclarativeLanguageServerLaunchPlanning, + resolver: MacPluginLanguageServerLaunchResolver, core: RustCoreBridge) { + self.registry = registry + self.packages = packages + self.host = host + self.planner = planner + self.resolver = resolver + self.core = core + } + + func identity(_ root: URL) -> String? { registry.resolve(root)?.binding.sessionID } + + func prepareLanguage(_ descriptor: LanguageServerLaunchDescriptor, workspaceURL: URL, + tools: any BuildToolResolving, versionChecker: any LanguageServerRuntimeVersionChecking) async throws -> PreparedLanguageServerProcess { + guard let binding = registry.resolve(workspaceURL)?.binding, let source = descriptor.launchSource else { + throw CocoaError(.featureUnsupported) + } + let prepared = try await prepare(pluginID: source.pluginID, packageURL: source.packageURL, + workspaceURL: workspaceURL, binding: binding, scope: "language-servers", provider: source.languageID) { manifest, request in + try self.planner.languageServerLaunchPlan(manifest: manifest, languageID: source.languageID, request: request) + } + do { + // Native integrations own additional filesystem and protocol data. + // Do not accidentally start them as a generic stdio server. + guard prepared.plan.hostIntegration == nil else { throw CocoaError(.featureUnsupported) } + let process = try process(prepared.plan, root: prepared.packageURL, + settings: resolver.settingValues(forPlugin: source.pluginID, workspaceRootURL: workspaceURL), + executesProjectCode: false, mayUseNetwork: true) + let dependencies = process.runtimeTools.map { BuildInvocation.RuntimeDependency(id: $0, hostManaged: true) } + let candidates: [ExecutableCandidate] + var runtimes = dependencies + var requirement: LanguageServerExecutableVersionRequirement? + switch process.executable { + case .file(let url): candidates = [.setting(id: "pluginPackage", path: url.path)] + case .commands(let commands): candidates = commands.map { .command($0) } + case .runtime(let tool, let version): + candidates = [.runtime(tool)] + requirement = version + runtimes.insert(.init(id: tool, hostManaged: true, minimumVersion: version?.minimumVersion, + versionArguments: version?.probeArguments), at: 0) + } + let version = requirement + let resolved = try await tools.resolve(BuildInvocation(executables: candidates, runtimeDependencies: runtimes), + displayName: source.languageID, workspaceURL: workspaceURL, checkVersion: { _, output in + guard let version else { return .init(satisfied: true, version: nil) } + let checked = try versionChecker.checkRuntimeVersion(version, output: output) + return .init(satisfied: checked.satisfied, version: checked.version) + }) + try Task.checkCancellation() + guard prepared.owner.isCurrent() else { throw CancellationError() } + var environment = resolved.environment + environment.merge(descriptor.environment) { _, configured in configured } + return PreparedLanguageServerProcess(executableURL: resolved.executableURL, environment: environment, + launch: ResolvedLanguageServerLaunch(arguments: prepared.plan.arguments, + initializationOptions: prepared.plan.initializationOptions, workspaceConfiguration: prepared.plan.workspaceConfiguration), + core: prepared.owner.core, release: prepared.owner.release) + } catch { await prepared.owner.release(); throw error } + } + + func prepareBuild(_ target: BuildProviderTarget) async throws -> Build? { + guard let binding = registry.resolve(target.workspaceURL)?.binding else { return nil } + let prepared = try await prepare(pluginID: target.pluginID, packageURL: target.packageURL, + workspaceURL: target.workspaceURL, binding: binding, scope: "build-systems", provider: target.buildSystemID) { manifest, request in + try self.planner.buildProviderLaunchPlan(manifest: manifest, buildSystemID: target.buildSystemID, request: request) + } + do { + guard let provider = prepared.plan.buildProvider, provider.buildSystemID == target.buildSystemID else { + throw CocoaError(.fileReadCorruptFile) + } + let launch: MacPluginLanguageServerLaunchResolver.BuildProviderLaunch + if let language = provider.languageServer { launch = .languageServer(language) } + else { + launch = .process(try process(prepared.plan, root: prepared.packageURL, + settings: resolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL), + executesProjectCode: provider.executesProjectCode, mayUseNetwork: provider.mayUseNetwork)) + } + return Build(launch: launch, owner: prepared.owner) + } catch { await prepared.owner.release(); throw error } + } + + func prepareVcs(_ target: VcsProviderTarget) async throws -> Vcs? { + guard let binding = registry.resolve(target.workspaceURL)?.binding else { return nil } + let prepared = try await prepare(pluginID: target.pluginID, packageURL: target.packageURL, + workspaceURL: target.workspaceURL, binding: binding, scope: "vcs-providers", provider: target.vcs) { manifest, request in + try self.planner.vcsProviderLaunchPlan(manifest: manifest, vcs: target.vcs, request: request) + } + do { + guard let provider = prepared.plan.vcsProvider, provider.vcs == target.vcs else { throw CocoaError(.fileReadCorruptFile) } + let process = try process(prepared.plan, root: prepared.packageURL, + settings: resolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL), + executesProjectCode: false, mayUseNetwork: provider.mayUseNetwork) + return Vcs(launch: .init(process: process, vcs: provider.vcs, toolCommands: provider.toolCommands, + toolValidationArguments: provider.toolValidationArguments), owner: prepared.owner) + } catch { await prepared.owner.release(); throw error } + } + + private struct Prepared { + let plan: DeclarativeLanguageServerLaunchPlan + let packageURL: URL + let owner: Owner + } + + private func prepare(pluginID: String, packageURL: URL, workspaceURL: URL, binding: RemoteWorkspaceBinding, + scope: String, provider: String, + plan: (Data, DeclarativeLanguageServerLaunchRequest) throws -> DeclarativeLanguageServerLaunchPlan) async throws -> Prepared { + let deployed = try await packages.deploy(pluginID: pluginID, packageURL: packageURL, session: binding.sessionID) + do { + try Task.checkCancellation() + let facts = try await host.pluginHost(session: binding.sessionID) + guard let home = facts.homeDirectory, home.hasPrefix("/"), !home.contains("\0") else { throw CocoaError(.fileReadCorruptFile) } + let state = URL(fileURLWithPath: home).appendingPathComponent(".lithe-server/state/plugins") + .appendingPathComponent(digest(pluginID)).appendingPathComponent(scope).appendingPathComponent(digest(provider)) + let workspaceState = state.appendingPathComponent("workspaces").appendingPathComponent(digest(binding.remoteRoot)) + let request = DeclarativeLanguageServerLaunchRequest(pluginDirectory: deployed.packageURL, stateDirectory: state, + workspaceStateDirectory: workspaceState, architecture: deployed.architecture, + settings: resolver.globalSettingValues(forPlugin: pluginID), + projectSettings: resolver.projectSettingValues(forPlugin: pluginID, workspaceRootURL: workspaceURL)) + let expanded = try plan(deployed.manifest, request) + for path in expanded.requiredPackagePaths { + let value = try await host.pluginRequest("fs.stat", params: .object([ + "path": .string(deployed.packageURL.appendingPathComponent(path).path)]), session: binding.sessionID) + guard case .object(let metadata) = value, + metadata["kind"] == .string("file") || metadata["kind"] == .string("directory") else { + throw CocoaError(.fileReadCorruptFile) + } + } + for directory in expanded.requiredDirectories { + let url = directory == .state ? state : workspaceState + _ = try await host.pluginRequest("fs.createDir", params: .object([ + "path": .string(url.path), "recursive": .bool(true), "mode": .integer(0o700)]), session: binding.sessionID) + } + try Task.checkCancellation() + guard identity(workspaceURL) == binding.sessionID else { throw CancellationError() } + let fixed = RemoteWorkspaceRegistry() + fixed.register(binding) + var fixedCore = core + fixedCore.remoteWorkspaces = fixed + return Prepared(plan: expanded, packageURL: deployed.packageURL, + owner: Owner(core: fixedCore, release: deployed.release, isCurrent: { + self.identity(workspaceURL) == binding.sessionID + })) + } catch { await deployed.release(); throw error } + } + + private func process(_ plan: DeclarativeLanguageServerLaunchPlan, root: URL, + settings: [String: ToolingJSONValue], executesProjectCode: Bool, + mayUseNetwork: Bool) throws -> MacPluginLanguageServerLaunchResolver.BuildProviderProcess { + let executable: MacPluginLanguageServerLaunchResolver.BuildProviderProcess.Executable + switch plan.executable { + case .packaged(let path)?: executable = .file(root.appendingPathComponent(path)) + case .systemCommand(let commands)?: executable = .commands(commands) + case .runtime(let tool)?: + executable = plan.packagedRuntimePath.map { .file(root.appendingPathComponent($0)) } + ?? .runtime(tool, versionRequirement: plan.executableVersionRequirement) + case nil: throw CocoaError(.fileReadCorruptFile) + } + return .init(executable: executable, arguments: plan.arguments, initializationOptions: plan.initializationOptions, + runtimeTools: plan.runtimeRequirements.map(\.tool), executesProjectCode: executesProjectCode, + mayUseNetwork: mayUseNetwork, settings: settings) + } + + private func digest(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift new file mode 100644 index 0000000000..8a2cd006a3 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift @@ -0,0 +1,246 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import LithePluginPackageSigning + +/// Deploys a fresh private snapshot of an approved package for one execution. +/// The host receives files only: no package manager, Core or archive extractor +/// is added to the small daemon. The caller owns the returned release action. +struct MacRemotePluginPackageDeployer: RemotePluginPackageDeploying { + let installed: [InstalledPluginPackage] + let makeStore: @Sendable (String) -> MacPluginPackageStore + let evaluator: any PluginMarketplaceEvaluating + let transport: any MacPluginMarketplaceTransport + let host: any RemotePluginFileAccess + let temporaryDirectory: URL + + func buildRequiresPackage(pluginID: String, buildSystemID: String, packageURL: URL) throws -> Bool { + guard let original = installed.first(where: { + $0.descriptor.id.rawValue == pluginID && $0.packageURL.standardizedFileURL == packageURL.standardizedFileURL + }), let manifest = try JSONSerialization.jsonObject(with: original.descriptor.normalizedManifest) as? [String: Any], + let builds = manifest["buildSystems"] as? [[String: Any]], + let build = builds.first(where: { $0["id"] as? String == buildSystemID }) else { + throw failure("plugin.remote.unapproved", L10n.string("The remote plugin package is not part of the approved installation.")) + } + func referencesPackage(_ value: Any) -> Bool { + if let text = value as? String { return text.contains("${pluginDir}") } + if let values = value as? [Any] { return values.contains(where: referencesPackage) } + if let object = value as? [String: Any] { return object.values.contains(where: referencesPackage) } + return false + } + return referencesPackage(build) + } + + func deploy(pluginID: String, packageURL: URL, session: String) async throws -> RemoteDeployedPluginPackage { + guard let original = installed.first(where: { + $0.descriptor.id.rawValue == pluginID && $0.packageURL.standardizedFileURL == packageURL.standardizedFileURL + }) else { throw failure("plugin.remote.unapproved", L10n.string("The remote plugin package is not part of the approved installation.")) } + let facts = try await host.pluginHost(session: session) + guard let platform = facts.platform, facts.features.contains("upload") else { + throw failure("plugin.remote.unsupported", L10n.string("This host cannot receive a compatible plugin package. Update the remote server.")) + } + // Revalidate the original approval before using its source or programs. + _ = try await worker { + try makeStore(MacPluginLanguageServerLaunchResolver.hostArchitecture).verifyCandidatePackage( + at: packageURL, origin: original.installation.origin, + marketplace: original.installation.marketplace, disk: original.installation.disk) + } + let local: LocalSnapshot + if platform == MacPluginMarketplaceService.currentPlatform { + local = try await snapshot(original, packageURL: packageURL, architecture: facts.templateArchitecture) + } else { + guard let provenance = original.installation.marketplace, + let indexURL = URL(string: try evaluator.resolveSource(provenance.repository).indexURL) else { + throw incompatible(pluginID, platform) + } + let kind: PluginMarketplaceSourceKind = original.installation.origin == .community ? .thirdParty : .official + let indexData = try await transport.data(from: indexURL, maximumBytes: 5 * 1_048_576) + let index = try evaluator.validateIndex(indexData, sourceKind: kind, platform: platform) + guard let entry = index.plugins.first(where: { $0.id == pluginID && $0.repository == provenance.repository }), + let download = entry.availability.download, + download.version == original.manifest.version.description, + let url = URL(string: download.downloadURL) else { throw incompatible(pluginID, platform) } + let downloaded = try await worker { + try await MacPluginPackageDownloader(transport: transport, temporaryDirectory: temporaryDirectory).download( + archiveURL: url, expectedSHA256: download.sha256, pluginID: original.descriptor.id) + } + do { + local = try await worker { + let store = makeStore(facts.templateArchitecture) + let descriptor = try store.inspectPackage(at: downloaded.packageURL) + let review = try evaluator.installReview(manifest: descriptor.normalizedManifest, expectedID: pluginID, + expectedVersion: original.manifest.version.description, + approvedPrograms: try provenance.approvedPrograms.map { try PluginProgram(canonicalJSON: $0) }) + // A target variant must not acquire unapproved permissions or + // change the declaration consumed by the current module graph. + guard review.newPrograms.isEmpty, + descriptor.normalizedManifest == original.descriptor.normalizedManifest else { + throw failure("plugin.remote.approvalRequired", L10n.string("The remote plugin package changes approved commands or permissions. Install and approve that version first.")) + } + let remoteProvenance = PluginMarketplaceProvenance(sourceID: provenance.sourceID, + sourceName: provenance.sourceName, repository: provenance.repository, + archiveSHA256: download.sha256, + contentDigest: try PluginPackageSignature.contentDigest(packageAt: downloaded.packageURL), + approvedPrograms: provenance.approvedPrograms) + _ = try store.verifyCandidatePackage(at: downloaded.packageURL, origin: original.installation.origin, + marketplace: remoteProvenance) + let snapshot = try LocalSnapshot.read(at: downloaded.packageURL, descriptor: descriptor) + _ = try store.verifyCandidatePackage(at: downloaded.packageURL, origin: original.installation.origin, + marketplace: remoteProvenance) + return snapshot + } + } catch { + await removeLocal(downloaded.temporaryDirectory) + throw error + } + // Files are streamed from this extraction tree below. Its release + // must occur only after the last upload or an upload failure. + return try await transfer(local, session: session, facts: facts, + localCleanup: downloaded.temporaryDirectory) + } + return try await transfer(local, session: session, facts: facts, localCleanup: nil) + } + + private func snapshot(_ original: InstalledPluginPackage, packageURL: URL, architecture: String) async throws -> LocalSnapshot { + try await worker { + let store = makeStore(architecture) + let descriptor = try store.verifyCandidatePackage(at: packageURL, + origin: original.installation.origin, marketplace: original.installation.marketplace, disk: original.installation.disk) + let snapshot = try LocalSnapshot.read(at: packageURL, descriptor: descriptor) + _ = try store.verifyCandidatePackage(at: packageURL, origin: original.installation.origin, + marketplace: original.installation.marketplace, disk: original.installation.disk) + return snapshot + } + } + + private func transfer(_ snapshot: LocalSnapshot, session: String, facts: RemotePluginHost, + localCleanup: URL?) async throws -> RemoteDeployedPluginPackage { + var remoteRoot: String? + do { + guard let home = facts.homeDirectory, home.hasPrefix("/"), !home.contains("\0"), + URL(fileURLWithPath: home).standardizedFileURL.path == home else { + throw CocoaError(.fileReadCorruptFile) + } + let parent = home + "/.lithe-server/plugin-runs" + _ = try await host.pluginRequest("fs.createDir", params: .object([ + "path": .string(parent), "recursive": .bool(true), "mode": .integer(0o700)]), session: session) + for directory in [home + "/.lithe-server", parent] { + let metadata = try await host.pluginRequest("fs.stat", params: .object(["path": .string(directory)]), session: session) + guard case .object(let value) = metadata, value["kind"] == .string("directory"), + case .integer(let mode) = value["mode"], mode & 0o077 == 0 else { + throw CocoaError(.fileWriteNoPermission) + } + } + let resolved = try await host.pluginRequest("fs.realPath", params: .object(["path": .string(parent)]), session: session) + guard case .object(let value) = resolved, case .string(let canonical) = value["path"] else { throw CocoaError(.fileReadCorruptFile) } + // Keep the exact owned path before allocation: cancellation after + // the host creates it still has a known cleanup destination. + let root = canonical + "/" + UUID().uuidString + remoteRoot = root + _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(root), "mode": .integer(0o700)]), session: session) + let package = root + "/package" + _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(package), "mode": .integer(0o700)]), session: session) + for entry in snapshot.entries { + try Task.checkCancellation() + let target = package + "/" + entry.path + if entry.directory { + _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(target), "mode": .integer(0o700)]), session: session) + } else { + try await host.uploadPluginFile(snapshot.root.appendingPathComponent(entry.path), to: target, + sha256: entry.sha256, mode: entry.executable ? 0o500 : 0o400, session: session) + } + } + // Detect package replacement or mutation during transfer, including + // mode changes not covered by the publisher's content signature. + let after = try await worker { try LocalSnapshot.read(at: snapshot.root, descriptor: snapshot.descriptor) } + guard snapshot.entries == after.entries else { throw CocoaError(.fileReadCorruptFile) } + if let localCleanup { await removeLocal(localCleanup) } + return RemoteDeployedPluginPackage(packageURL: URL(fileURLWithPath: package, isDirectory: true), + manifest: snapshot.descriptor.normalizedManifest, architecture: facts.templateArchitecture) { + await release(root, session: session) + } + } catch { + if let remoteRoot { await release(remoteRoot, session: session) } + if let localCleanup { await removeLocal(localCleanup) } + throw error + } + } + + private func release(_ root: String, session: String) async { + // A cancelled build still needs an uncancelled cleanup operation on its + // original host. Directories stay private and owner-writable for removal; + // runtime state is always outside this snapshot and files are read-only. + let cleanup = Task.detached { + do { + do { + _ = try await host.pluginRequest("fs.remove", params: .object(["path": .string(root), "recursive": .bool(true)]), session: session) + } catch let error as RemoteProbeFailure where error.code == "workspace_not_found" { + // Cancellation may have won before the allocation. + } + } catch { NSLog("Remote plugin snapshot cleanup failed (%@)", String(describing: type(of: error))) } + } + await cleanup.value + } + + private func removeLocal(_ directory: URL) async { + await Task.detached { + do { try FileManager().removeItem(at: directory) } + catch { NSLog("Remote plugin extraction cleanup failed (%@)", String(describing: type(of: error))) } + }.value + } + + private func worker(_ operation: @escaping @Sendable () async throws -> Value) async throws -> Value { + let task = Task.detached(priority: .utility, operation: operation) + return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + } + + private func failure(_ code: String, _ message: String) -> DeclarativePluginRejection { + DeclarativePluginRejection(code: code, message: message) + } + + private func incompatible(_ id: String, _ platform: String) -> DeclarativePluginRejection { + failure("plugin.remote.platformUnavailable", L10n.string("Plugin \(id) has no approved package for \(platform). Install a compatible plugin version.")) + } +} + +private struct LocalSnapshot: Sendable { + struct Entry: Equatable, Sendable { + let path: String + let directory: Bool + let executable: Bool + let sha256: String + } + let root: URL + let descriptor: DeclarativePluginDescriptor + let entries: [Entry] + + static func read(at root: URL, descriptor: DeclarativePluginDescriptor) throws -> Self { + let manager = FileManager() + let root = root.standardizedFileURL + var entries: [Entry] = [] + var total: Int64 = 0 + func visit(_ directory: URL, relative: String) throws { + for url in try manager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil).sorted(by: { $0.path < $1.path }) { + try Task.checkCancellation() + guard entries.count < 50_000 else { throw CocoaError(.fileReadTooLarge) } + let values = try url.resourceValues(forKeys: [.isSymbolicLinkKey, .isRegularFileKey, .isDirectoryKey, .fileSizeKey]) + guard values.isSymbolicLink != true, values.isDirectory == true || values.isRegularFile == true else { + throw CocoaError(.fileReadUnsupportedScheme) + } + // Directory enumeration may canonicalize /var to /private/var. + // Build relative names from components, never string offsets. + let path = relative + url.lastPathComponent + guard !path.isEmpty, !path.contains("\0"), path.utf8.count <= 4096 else { throw CocoaError(.fileReadInvalidFileName) } + total += Int64(values.fileSize ?? 0) + guard total <= 2 * 1024 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } + let directory = values.isDirectory == true + entries.append(Entry(path: path, directory: directory, executable: !directory && manager.isExecutableFile(atPath: url.path), + sha256: directory ? "" : try MacPluginPackageDownloader.sha256(of: url))) + if directory { try visit(url, relative: path + "/") } + } + } + try visit(root, relative: "") + return Self(root: root, descriptor: descriptor, entries: entries) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift new file mode 100644 index 0000000000..0336d68363 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift @@ -0,0 +1,239 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// A workspace-owned host snapshot. UI accessors never perform network I/O or +/// inspect this Mac; only discover runs host probes on the service's worker. +final class MacRemoteRuntimeLocator: RuntimeLocator, RuntimeToolDiscovery, @unchecked Sendable { + private struct Snapshot { + var environment: [String: String] = [:] + var executables: Set = [] + var java: [JavaRuntimeCandidate] = [] + var maven: [MavenRuntimeCandidate] = [] + var pathJava: String? + var failure: String? + var requirements: ToolingJSONValue? + } + private let binding: RemoteWorkspaceBinding + private let host: any RemoteRuntimeProbing + private let lock = NSLock() + private var snapshot = Snapshot() + private var configured: [String] = [] + private var generation: UInt64 = 0 + + init(binding: RemoteWorkspaceBinding, host: any RemoteRuntimeProbing) { + self.binding = binding + self.host = host + } + + var discoveryFailure: String? { lock.withLock { snapshot.failure } } + func environment() -> [String: String] { lock.withLock { snapshot.environment } } + func configureProbePaths(_ paths: [String]) { + lock.withLock { + guard configured != paths else { return } + configured = paths + generation &+= 1 + snapshot = Snapshot() + } + } + func invalidateProbeCache() { lock.withLock { generation &+= 1; snapshot = Snapshot() } } + + func selectJavaRuntime(candidates: [AutomaticJavaCandidate], fallbackID: String?) -> Result? { + let current = lock.withLock { snapshot } + guard let requirements = current.requirements else { + return .failure(.init(code: "runtime_discovery_failed", + message: current.failure ?? L10n.string("Could not discover runtimes on the remote host. Check the connection and refresh."), details: nil)) + } + return RustCoreBridge().selectJavaRuntime(requirements: requirements, candidates: candidates, fallbackID: fallbackID) + } + + func normalizedPath(_ path: String, relativeTo root: URL?) -> String { + var value = path.trimmingCharacters(in: .whitespacesAndNewlines) + if value == "~" || value.hasPrefix("~/") { + guard let home = environment()["HOME"], home.hasPrefix("/") else { return value } + value = home + value.dropFirst() + } + if !value.hasPrefix("/"), let root { value = root.appendingPathComponent(value).path } + let local = binding.root.path + if value == local { value = binding.remoteRoot } + else if value.hasPrefix(local + "/") { value = binding.remoteRoot + value.dropFirst(local.count) } + // NSString.standardizingPath expands ~ with the local account; only + // absolute host paths may reach Foundation's normalization here. + return value.hasPrefix("/") ? URL(fileURLWithPath: value).standardizedFileURL.path : value + } + + func discover() -> RuntimeDiscoveryResult { discover(isCancelled: { false }) } + func discover(isCancelled: @Sendable () -> Bool) -> RuntimeDiscoveryResult { + let (ticket, paths) = lock.withLock { () -> (UInt64, [String]) in + generation &+= 1 + return (generation, configured) + } + var next = Snapshot() + let deadline = ContinuousClock.now.advanced(by: .seconds(60)) + func check() throws { + if isCancelled() || lock.withLock({ generation != ticket }) { throw CancellationError() } + if ContinuousClock.now >= deadline { throw ProbeError.deadline } + } + func optional(_ operation: () throws -> Value) throws -> Value? { + try check() + do { return try operation() } + catch let error as RustCoreBridge.CoreCallError + where error.code == "workspace_not_found" || error.details?.contains("NOT_A_DIRECTORY") == true { return nil } + catch let error as RemoteProbeFailure where error.code == "not_found" { return nil } + } + func executable(_ path: String) throws -> Bool { + let found = try optional { try host.runtimeExecutable(path, session: binding.sessionID) } ?? false + if found { next.executables.insert(path) } + return found + } + func entries(_ path: String) throws -> [RemoteDirectoryEntry] { + let values = try optional { try host.runtimeDirectory(path, session: binding.sessionID) } ?? [] + guard values.count <= 16_384 else { throw ProbeError.limit } + return values + } + func canonical(_ path: String) throws -> String { + try check() + return try host.runtimeRealPath(path, session: binding.sessionID) + } + do { + try check() + next.environment = try host.runtimeEnvironment(session: binding.sessionID) + try check() + next.requirements = try host.runtimeRequirements(binding.remoteRoot, session: binding.sessionID) + guard case .object = next.requirements else { throw ProbeError.invalidRequirements } + if let requirements = next.requirements { + _ = try RustCoreBridge().selectJavaRuntime(requirements: requirements, candidates: [], fallbackID: nil).get() + } + var homes = Set() + if let home = next.environment["JAVA_HOME"], home.hasPrefix("/") { homes.insert(home) } + for path in paths where !path.isEmpty { + // Use the just-read remote HOME, not the previous snapshot. + let expanded = path == "~" || path.hasPrefix("~/") ? (next.environment["HOME"] ?? "~") + path.dropFirst() : path + let resolved = expanded.hasPrefix("/") ? expanded : binding.remoteRoot + "/" + expanded + homes.insert(resolved) + if !(try executable(resolved)) { + for tool in ["mvn", "mvnd"] { + _ = try executable(resolved + "/bin/" + tool) + _ = try executable(resolved + "/" + tool) + } + } + } + let home = next.environment["HOME"] ?? "" + var roots = ["/usr/lib/jvm", "/Library/Java/JavaVirtualMachines"] + if home.hasPrefix("/") { + roots += [home + "/Library/Java/JavaVirtualMachines", + (next.environment["SDKMAN_DIR"] ?? home + "/.sdkman") + "/candidates/java"] + } + for root in roots { + for entry in try entries(root) where entry.isDirectory { + let path = root + "/" + entry.name + homes.insert(root.contains("JavaVirtualMachines") ? path + "/Contents/Home" : path) + } + } + // Login PATH already includes the host's configured version managers. + let searchPath = (next.environment["PATH"] ?? "").split(separator: ":").map(String.init).filter { $0.hasPrefix("/") } + guard searchPath.count <= 64 else { throw ProbeError.limit } + for directory in searchPath { + for entry in try entries(directory) where entry.metadata.kind == "file" || entry.metadata.kind == "symlink" { + let path = directory + "/" + entry.name + if entry.metadata.kind == "symlink" { + guard entry.metadata.symlinkTargetKind == "file" else { continue } + if let mode = entry.metadata.symlinkTargetMode { + guard mode & 0o111 != 0 else { continue } + } else if !(try executable(path)) { continue } + } else if let mode = entry.metadata.mode { + guard mode & 0o111 != 0 else { continue } + } else if !(try executable(path)) { continue } + next.executables.insert(path) + guard next.executables.count <= 65_536 else { throw ProbeError.limit } + if entry.name == "java", next.pathJava == nil { + let resolved = try canonical(path) + if resolved != "/usr/bin/java" { + let home = URL(fileURLWithPath: resolved).deletingLastPathComponent().deletingLastPathComponent().path + next.pathJava = home + homes.insert(home) + } + } + } + } + for relative in ["mvnw", "gradlew"] { _ = try executable(binding.remoteRoot + "/" + relative) } + guard homes.count <= 32 else { throw ProbeError.limit } + var seen = Set() + for home in homes.sorted() where try executable(home + "/bin/java") { + let resolved = try canonical(home) + guard seen.insert(resolved).inserted else { continue } + next.executables.insert(resolved + "/bin/java") + try check() + if let java = MacRuntimeDiscovery.javaRuntime(home: URL(fileURLWithPath: resolved), + output: try host.runtimeVersion(home + "/bin/java", session: binding.sessionID)) { + next.java.append(java) + } + } + var mavenPaths = next.executables.filter { ["mvn", "mvnd"].contains(URL(fileURLWithPath: $0).lastPathComponent) }.sorted() + for key in ["MAVEN_HOME", "M2_HOME", "MVND_HOME"] { + if let home = next.environment[key], home.hasPrefix("/") { + let path = home + "/bin/" + (key == "MVND_HOME" ? "mvnd" : "mvn") + if try executable(path) { mavenPaths.insert(path, at: 0) } + } + } + guard mavenPaths.count <= 16 else { throw ProbeError.limit } + for path in mavenPaths { + try check() + let output = try host.runtimeVersion(path, session: binding.sessionID) + if let version = MacRuntimeDiscovery.mavenVersion(in: output) { + let resolved = try canonical(path) + next.maven.append(MavenRuntimeCandidate(homePath: URL(fileURLWithPath: resolved).deletingLastPathComponent().deletingLastPathComponent().path, + executablePath: path, version: version)) + } + } + next.java.sort { $0.version.localizedStandardCompare($1.version) == .orderedDescending } + next.maven.sort { ($0.executablePath.hasSuffix("/mvnd") ? 1 : 0) < ($1.executablePath.hasSuffix("/mvnd") ? 1 : 0) } + } catch is CancellationError { return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + catch { + // A failed/incomplete host snapshot must never authorize a launch. + next = Snapshot() + next.failure = L10n.string("Could not discover runtimes on the remote host. Check the connection and refresh.") + } + guard !isCancelled() else { return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + let published = lock.withLock { () -> Bool in + guard generation == ticket else { return false } + snapshot = next + return true + } + guard published else { return RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) } + return RuntimeDiscoveryResult(javaRuntimes: next.java, mavenRuntimes: next.maven) + } + + private enum ProbeError: Error { case deadline, limit, invalidRequirements } + func discoverJavaRuntimes() -> [JavaRuntimeCandidate] { lock.withLock { snapshot.java } } + func discoverJavaRuntimes(isCancelled: @Sendable () -> Bool) -> [JavaRuntimeCandidate] { isCancelled() ? [] : discoverJavaRuntimes() } + func validJavaHome(path: String) -> URL? { + let path = normalizedPath(path, relativeTo: binding.root) + return isExecutable(at: URL(fileURLWithPath: path + "/bin/java")) ? URL(fileURLWithPath: path) : nil + } + func javaHomeOnPath(in candidates: [JavaRuntimeCandidate]) -> URL? { + lock.withLock { snapshot.pathJava.map { URL(fileURLWithPath: $0) } } + } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { lock.withLock { snapshot.java.first { $0.homePath == homeURL.path } } } + func isExecutable(at url: URL) -> Bool { lock.withLock { snapshot.executables.contains(normalizedPathWithoutLock(url.path)) } } + private func normalizedPathWithoutLock(_ path: String) -> String { + let root = binding.root.path + return path == root ? binding.remoteRoot : path.hasPrefix(root + "/") ? binding.remoteRoot + path.dropFirst(root.count) : path + } + func systemMavenExecutable() -> URL? { lock.withLock { snapshot.maven.first.map { URL(fileURLWithPath: $0.executablePath) } } } + func mavenExecutable(forHomePath path: String) -> URL? { + let path = normalizedPath(path, relativeTo: binding.root) + return [path, path + "/bin/mvnd", path + "/bin/mvn"].first { isExecutable(at: URL(fileURLWithPath: $0)) }.map { URL(fileURLWithPath: $0) } + } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { lock.withLock { snapshot.maven.first { $0.executablePath == executableURL.path } } } + func candidates(for command: String, projectURL: URL?, environment: [String: String]) -> [RuntimeToolCandidate] { + guard !command.isEmpty, !command.contains("/") else { return [] } + return (environment["PATH"] ?? "").split(separator: ":").map { URL(fileURLWithPath: String($0)).appendingPathComponent(command) } + .filter { isExecutable(at: $0) }.map { RuntimeToolCandidate(command: command, executableURL: $0, source: .path, detail: nil) } + } + func guidance(for command: String, projectURL: URL?, environment: [String: String]) -> RuntimeToolGuidance { + RuntimeToolGuidance(command: command, + summary: L10n.string("The required tool is not available on the remote host."), + recovery: L10n.string("Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes.")) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift new file mode 100644 index 0000000000..e77bb17534 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteShellDiscovery.swift @@ -0,0 +1,82 @@ +import Foundation +import LitheModuleAPI + +/// Discovers executable shells using only the selected host's login environment +/// and filesystem. Directory batches avoid one network round trip per candidate. +enum MacRemoteShellDiscovery { + static func discover(session: String, host: any RemoteShellProbing) async throws -> [String] { + let cancellation = Cancellation() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { + try availableShells(session: session, host: host, isCancelled: { cancellation.isCancelled }) + }) + } + } + } onCancel: { cancellation.cancel() } + } + + static func availableShells( + session: String, host: any RemoteShellProbing, isCancelled: () -> Bool + ) throws -> [String] { + let deadline = ContinuousClock.now + .seconds(30) + func check() throws { + if isCancelled() { throw CancellationError() } + if ContinuousClock.now >= deadline { throw DiscoveryError.failed } + } + do { + try check() + let environment = try host.shellEnvironment(session: session) + try check() + let registered = try host.registeredShells(session: session) + let candidates = MacTerminalShellDiscovery.candidates( + environment: environment, registeredShells: registered, searchDefaults: []) + guard candidates.count <= 1_024 else { throw DiscoveryError.failed } + let directories = Set(candidates.map { URL(fileURLWithPath: $0).deletingLastPathComponent().path }) + guard directories.count <= 64 else { throw DiscoveryError.failed } + var executable = Set() + for directory in directories.sorted() { + try check() + let entries: [RemoteDirectoryEntry] + do { entries = try host.runtimeDirectory(directory, session: session) } + catch let error as RustCoreBridge.CoreCallError where error.code == "workspace_not_found" { continue } + guard entries.count <= 16_384 else { throw DiscoveryError.failed } + let names = Set(candidates.filter { URL(fileURLWithPath: $0).deletingLastPathComponent().path == directory } + .map { URL(fileURLWithPath: $0).lastPathComponent }) + for entry in entries where names.contains(entry.name) { + let path = URL(fileURLWithPath: directory).appendingPathComponent(entry.name).path + let metadata = entry.metadata + if metadata.kind == "file", let mode = metadata.mode, mode & 0o111 != 0 { + executable.insert(path) + } else if metadata.kind == "symlink", metadata.symlinkTargetKind == "file" { + if let mode = metadata.symlinkTargetMode { + if mode & 0o111 != 0 { executable.insert(path) } + } else { + // Older servers report target kind without permissions. + try check() + if try host.runtimeExecutable(path, session: session) { executable.insert(path) } + } + } + } + } + try check() + return candidates.filter { executable.contains($0) } + } catch is CancellationError { throw CancellationError() } + catch { throw DiscoveryError.failed } + } + + private enum DiscoveryError: LocalizedError { + case failed + var errorDescription: String? { + L10n.string("Could not discover shells on the remote host. Check the connection and try again.") + } + } + + private final class Cancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + var isCancelled: Bool { lock.withLock { cancelled } } + func cancel() { lock.withLock { cancelled = true } } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift new file mode 100644 index 0000000000..d162112c70 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift @@ -0,0 +1,312 @@ +import AppKit +import Foundation +import LitheModuleAPI +import LitheTerminalModule +import SwiftTerm + +/// Reuses the native terminal renderer while its PTY lives on the workspace +/// host. No local process or local login environment is used for this surface. +@MainActor +final class MacRemoteTerminalTransport: TerminalTransport { + private let renderer: MacTerminalTransport + private let binding: RemoteWorkspaceBinding + private let transport: any RemoteTerminalConnecting + private var delegate: RemoteTerminalViewDelegate? + private var run: Task? + private var controls: Task? + private var launch: CheckedContinuation? + private var terminalID: UInt64? + private var generation = UUID() + private var queuedInputBytes = 0 + private var pendingSize: (columns: UInt16, rows: UInt16)? + private var resizeQueued = false + private static let maximumQueuedInputBytes = 1024 * 1024 + + private(set) var isRunning = false + private(set) var processID: Int32? + var shellName: String { "Shell" } + var nativeView: AnyObject { renderer.nativeView } + var termProgramVersion: String? { renderer.termProgramVersion } + var reportsLightBackground: Bool? { renderer.reportsLightBackground } + var requiresAsyncStart: Bool { true } + var onTermination: ((Int32?) -> Void)? + var onOutput: ((Data) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onLink: ((String, [String: String]) -> Void)? + + init(renderer: MacTerminalTransport, binding: RemoteWorkspaceBinding, transport: any RemoteTerminalConnecting) { + self.renderer = renderer + self.binding = binding + self.transport = transport + let delegate = RemoteTerminalViewDelegate(view: renderer.view) + self.delegate = delegate + renderer.view.terminalDelegate = delegate + delegate.onInput = { [weak self] data in try? self?.send(data) } + delegate.onResize = { [weak self] columns, rows in self?.resize(columns: columns, rows: rows) } + renderer.onOutput = { [weak self] data in self?.onOutput?(data) } + renderer.onTitle = { [weak self] title in self?.onTitle?(title) } + renderer.onDirectoryUpdate = { [weak self] directory in + guard let self, let directory else { return } + let path = URL(string: directory).flatMap { $0.isFileURL ? $0.path : nil } ?? directory + onDirectoryUpdate?(localPath(forRemotePath: path) ?? binding.root.path) + } + renderer.onLink = { [weak self] link, params in + guard let self else { return } + onLink?(localPath(forRemotePath: link) ?? link, params) + } + } + + func configureInitialSurface(showsWorkbenchBackground: Bool) { + renderer.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + } + + func defaultShellPath() -> String { "Shell" } + func defaultEnvironment() -> [String: String] { [:] } + + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { + throw RemoteTerminalFailure.asyncStartRequired + } + + func startShell(workingDirectory: String, shellPath: String, environment: [String: String]) async throws { + let size = renderer.view.getWindowSize() + let request = RemoteTerminalRequest( + argv: shellPath == defaultShellPath() ? nil : [shellPath] + MacTerminalShellDiscovery.startupArguments(for: shellPath), + cwd: try remoteDirectory(workingDirectory, allowsHostPath: false), + env: environment, columns: max(1, size.ws_col), rows: max(1, size.ws_row) + ) + try await start(request) + } + + func startProcessAsync(_ launch: TerminalProcessLaunch, environment: [String: String]) async throws -> Int32 { + let size = renderer.view.getWindowSize() + let request = RemoteTerminalRequest( + argv: [remotePath(forLocalPath: launch.executablePath)] + launch.arguments, + cwd: try remoteDirectory(launch.workingDirectory, allowsHostPath: true), + env: environment, columns: max(1, size.ws_col), rows: max(1, size.ws_row) + ) + try await start(request) + guard let processID else { throw RemoteTerminalFailure.closedBeforeStart } + return processID + } + + private func start(_ request: RemoteTerminalRequest) async throws { + let previousRun = run + let previousControls = controls + stop() + let stoppedGeneration = generation + await previousRun?.value + await previousControls?.value + // Closing this tab while its predecessor cleans up must not start a + // replacement process after the close has already returned. + guard generation == stoppedGeneration else { throw CancellationError() } + try Task.checkCancellation() + let generation = UUID() + self.generation = generation + let transport = transport + let session = binding.sessionID + renderer.clear() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + launch = continuation + run = Task { [weak self] in + let result = await transport.runTerminal(request, session: session) { [weak self] event in + // The C observer runs on the adapter's blocking worker. + // Waiting for the renderer bounds buffered output and + // propagates backpressure through SSH to the remote PTY. + if Thread.isMainThread { + MainActor.assumeIsolated { self?.receive(event, generation: generation) } + } else { + DispatchQueue.main.sync { + MainActor.assumeIsolated { self?.receive(event, generation: generation) } + } + } + } + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation else { return } + if let launch = self.launch { + self.launch = nil + switch result { + case .success: launch.resume(throwing: RemoteTerminalFailure.closedBeforeStart) + case .failure(let failure): launch.resume(throwing: failure) + } + } + // A failed connection may end without terminal.exit. + // Release the same identity and controls as an explicit stop. + let wasRunning = self.isRunning + self.stop() + if wasRunning { self.onTermination?(nil) } + } + } + } + } onCancel: { + Task { @MainActor [weak self] in + guard let self, self.generation == generation else { return } + stop() + } + } + } + + private func receive(_ event: RemoteTerminalEvent, generation: UUID) { + guard self.generation == generation else { return } + switch event { + case .started(let id, let pid): + terminalID = id + processID = pid + isRunning = true + renderer.view.hasRemoteProcess = true + let continuation = launch + launch = nil + continuation?.resume() + let size = renderer.view.getWindowSize() + resize(columns: Int(size.ws_col), rows: Int(size.ws_row)) + case .output(let data): renderer.view.dataReceived(slice: Array(data)[...]) + case .exited(let code): + guard isRunning else { return } + isRunning = false + processID = nil + terminalID = nil + pendingSize = nil + controls?.cancel() + renderer.view.hasRemoteProcess = false + onTermination?(code) + } + } + + func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 { + throw RemoteTerminalFailure.asyncStartRequired + } + + func send(_ input: Data) throws { + guard !input.isEmpty, isRunning, let id = terminalID else { return } + guard queuedInputBytes + input.count <= Self.maximumQueuedInputBytes else { + throw RemoteTerminalFailure.inputBusy + } + queuedInputBytes += input.count + let transport = transport + let session = binding.sessionID + enqueue(inputBytes: input.count) { + try await transport.writeTerminal(input, id: id, session: session) + } + } + + private func resize(columns: Int, rows: Int) { + guard isRunning else { return } + pendingSize = (UInt16(clamping: max(1, columns)), UInt16(clamping: max(1, rows))) + guard !resizeQueued else { return } + resizeQueued = true + let transport = transport + let session = binding.sessionID + let generation = generation + enqueue { [weak self] in + guard let self else { return } + defer { if self.generation == generation { resizeQueued = false } } + while self.generation == generation, let size = pendingSize, let id = terminalID { + pendingSize = nil + try await transport.resizeTerminal(id: id, columns: size.columns, rows: size.rows, session: session) + } + } + } + + private func enqueue(inputBytes: Int = 0, _ operation: @escaping @MainActor () async throws -> Void) { + let previous = controls + let generation = generation + controls = Task { [weak self] in + await previous?.value + guard !Task.isCancelled, self?.generation == generation else { return } + defer { + if self?.generation == generation { self?.queuedInputBytes -= inputBytes } + } + do { try await operation() } + catch { + guard let self, self.generation == generation else { return } + let wasRunning = isRunning + stop() + if wasRunning { onTermination?(nil) } + } + } + } + + func interrupt() throws { try send(Data([0x03])) } + func focus() { renderer.focus() } + func clear() { renderer.clear() } + func stop() { + generation = UUID() + isRunning = false + processID = nil + terminalID = nil + queuedInputBytes = 0 + pendingSize = nil + resizeQueued = false + renderer.view.hasRemoteProcess = false + run?.cancel() + controls?.cancel() + let continuation = launch + launch = nil + continuation?.resume(throwing: CancellationError()) + } + + func stopAndWait() async { + let run = run + let controls = controls + stop() + await run?.value + await controls?.value + } + + private func localPath(forRemotePath path: String) -> String? { + let root = binding.remoteRoot == "/" ? "" : binding.remoteRoot + guard path == root || path.hasPrefix(root + "/") else { return nil } + return binding.root.path + String(path.dropFirst(root.count)) + } + + private func remotePath(forLocalPath path: String) -> String { + let root = binding.root.standardizedFileURL.path + guard path == root || path.hasPrefix(root + "/") else { return path } + let remote = binding.remoteRoot == "/" ? "" : binding.remoteRoot + let mapped = remote + path.dropFirst(root.count) + return mapped.isEmpty ? "/" : mapped + } + + private func remoteDirectory(_ path: String, allowsHostPath: Bool) throws -> String { + let normalized = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL.path + let root = binding.root.standardizedFileURL.path + guard path.hasPrefix("/"), allowsHostPath || normalized == root || normalized.hasPrefix(root + "/") else { + throw RemoteTerminalFailure.invalidDirectory + } + return remotePath(forLocalPath: normalized) + } + + deinit { run?.cancel(); controls?.cancel() } +} + +/// SwiftTerm's local delegate ignores size changes without a local child. +/// Forward rendering/clipboard behavior to it and route only PTY IO remotely. +@MainActor +private final class RemoteTerminalViewDelegate: @preconcurrency TerminalViewDelegate { + private weak var view: LitheTerminalView? + var onInput: ((Data) -> Void)? + var onResize: ((Int, Int) -> Void)? + init(view: LitheTerminalView) { self.view = view } + func sizeChanged(source: SwiftTerm.TerminalView, newCols: Int, newRows: Int) { onResize?(newCols, newRows) } + func send(source: SwiftTerm.TerminalView, data: ArraySlice) { onInput?(Data(data)) } + func setTerminalTitle(source: SwiftTerm.TerminalView, title: String) { view?.setTerminalTitle(source: source, title: title) } + func hostCurrentDirectoryUpdate(source: SwiftTerm.TerminalView, directory: String?) { view?.hostCurrentDirectoryUpdate(source: source, directory: directory) } + func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) { view?.requestOpenLink(source: source, link: link, params: params) } + func scrolled(source: SwiftTerm.TerminalView, position: Double) { view?.scrolled(source: source, position: position) } + func rangeChanged(source: SwiftTerm.TerminalView, startY: Int, endY: Int) { view?.rangeChanged(source: source, startY: startY, endY: endY) } + func clipboardCopy(source: SwiftTerm.TerminalView, content: Data) { view?.clipboardCopy(source: source, content: content) } + func clipboardRead(source: SwiftTerm.TerminalView) -> Data? { view?.clipboardRead(source: source) } +} + +private enum RemoteTerminalFailure: LocalizedError { + case asyncStartRequired, invalidDirectory, closedBeforeStart, inputBusy + var errorDescription: String? { + switch self { + case .asyncStartRequired: L10n.string("The remote terminal must be started asynchronously.") + case .invalidDirectory: L10n.string("The terminal directory is outside the remote workspace.") + case .closedBeforeStart: L10n.string("The remote terminal closed before it started.") + case .inputBusy: L10n.string("The remote terminal is busy. Wait before sending more input.") + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift index 8891731cad..8ac6907d2e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift @@ -187,6 +187,11 @@ enum MacRuntimeDiscovery { arguments: ["-version"], dependencies: [home.appendingPathComponent("release")], accepts: { firstCapture(pattern: #"version\s+\"([^\"]+)\""#, in: $0) != nil } ) + return javaRuntime(home: home, output: output) + } + + /// Local and remote probes share the upstream version/banner parser. + static func javaRuntime(home: URL, output: String) -> JavaRuntimeCandidate? { guard let version = firstCapture(pattern: #"version\s+\"([^\"]+)\""#, in: output) else { return nil } diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift index 1582a732bf..c39f9a9e30 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalShellDiscovery.swift @@ -36,7 +36,8 @@ enum MacTerminalShellDiscovery { static func candidates( environment: [String: String], - registeredShells: String + registeredShells: String, + searchDefaults: [String]? = nil ) -> [String] { var candidates = [environment["SHELL"]].compactMap { $0 } candidates += registeredShells.split(whereSeparator: \.isNewline).compactMap { line in @@ -45,7 +46,7 @@ enum MacTerminalShellDiscovery { return path.isEmpty ? nil : path } let searchDirectories = (environment["PATH"] ?? "").split(separator: ":").map(String.init) - + standardSearchDirectories + + (searchDefaults ?? standardSearchDirectories) for directory in searchDirectories where directory.hasPrefix("/") { candidates += shellNames.map { URL(fileURLWithPath: directory).appendingPathComponent($0).path } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index a518205334..e6feb9dcb2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -7,6 +7,7 @@ import LitheTerminalModule /// SwiftTerm's default link handler opens URLs in the system. Lithe needs the /// link event so workspace-relative paths can open in its own editor instead. final class LitheTerminalView: LocalProcessTerminalView { + var hasRemoteProcess = false static let focusDidChange = Notification.Name("LitheTerminalView.focusDidChange") var onOpenLink: ((String, [String: String]) -> Void)? var onProcessOutput: ((Data) -> Void)? @@ -219,7 +220,7 @@ final class LitheTerminalView: LocalProcessTerminalView { let payload = MacTerminalBackgroundReporting.osc11Payload(for: nativeBackgroundColor) guard payload != lastReportedBackgroundPayload else { return } lastReportedBackgroundPayload = payload - guard process.running, + guard process.running || hasRemoteProcess, let notification = MacTerminalBackgroundReporting.changeNotification( for: nativeBackgroundColor, isSubscribed: backgroundChangeNotifications.isSubscribed diff --git a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift index edd3616e42..22bcccd95c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Vcs/MacVcsProviderSessions.swift @@ -28,6 +28,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { private let versionChecker: any LanguageServerRuntimeVersionChecking private let runtime: any LanguageToolRuntimePort private let core: any LanguageServerRuntimeCore + private let remoteLaunches: MacRemotePluginLaunchPreparer? private weak var processRegistry: (any LanguageServerProcessRegistry)? /// Ends the processes carrying a backend's token; `immediately` skips the /// graceful stop. See `MacPluginBackendProcesses`. @@ -37,11 +38,13 @@ final class MacVcsProviderSessions: VcsProviderRequesting { /// One process, from its start until it is dropped. private final class Running { let settings: [String: ToolingJSONValue] + let hostIdentity: String? var task: Task? var session: LanguageServerRuntimeSession? - init(settings: [String: ToolingJSONValue]) { + init(settings: [String: ToolingJSONValue], hostIdentity: String?) { self.settings = settings + self.hostIdentity = hostIdentity } } @@ -60,6 +63,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { runtime: any LanguageToolRuntimePort, core: any LanguageServerRuntimeCore, processRegistry: (any LanguageServerProcessRegistry)?, + remoteLaunches: MacRemotePluginLaunchPreparer? = nil, endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( token: token, @@ -72,6 +76,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { self.versionChecker = versionChecker self.runtime = runtime self.core = core + self.remoteLaunches = remoteLaunches self.processRegistry = processRegistry self.endBackendProcesses = endBackendProcesses } @@ -133,7 +138,8 @@ final class MacVcsProviderSessions: VcsProviderRequesting { try Task.checkCancellation() let settings = launchResolver.settingValues(forPlugin: target.pluginID, workspaceRootURL: target.workspaceURL) if let entry = running[target], let task = entry.task { - if entry.settings != settings || entry.session.map({ !$0.isRunning }) == true { + if entry.settings != settings || entry.session.map({ !$0.isRunning }) == true + || entry.hostIdentity != remoteLaunches?.identity(target.workspaceURL) { stop(entry, for: target) } else { let started = try await Self.waitForStart(task) @@ -141,7 +147,7 @@ final class MacVcsProviderSessions: VcsProviderRequesting { return started } } - let entry = Running(settings: settings) + let entry = Running(settings: settings, hostIdentity: remoteLaunches?.identity(target.workspaceURL)) running[target] = entry // The start is shared by every request that arrives meanwhile, so no // single caller's cancellation cancels it; a failed start clears the @@ -194,21 +200,27 @@ final class MacVcsProviderSessions: VcsProviderRequesting { } private func launch(_ target: VcsProviderTarget, entry: Running) async throws -> Started { - let provider = try launchResolver.resolveVcsProvider( + let prepared = try await remoteLaunches?.prepareVcs(target) + let provider = try prepared?.launch ?? launchResolver.resolveVcsProvider( pluginID: target.pluginID, vcs: target.vcs, manifest: target.manifest, packageURL: target.packageURL, workspaceRootURL: target.workspaceURL ) - var (executableURL, environment) = try await resolveExecutable(provider.process, workspaceURL: target.workspaceURL) - let toolPath = try await resolveTool(provider, workspaceURL: target.workspaceURL) - try Task.checkCancellation() - guard running[target] === entry else { throw CancellationError() } + let executableURL: URL + var environment: [String: String] + let toolPath: String? + do { + (executableURL, environment) = try await resolveExecutable(provider.process, workspaceURL: target.workspaceURL) + toolPath = try await resolveTool(provider, workspaceURL: target.workspaceURL) + try Task.checkCancellation() + guard running[target] === entry, prepared?.owner.isCurrent() != false else { throw CancellationError() } + } catch { await prepared?.owner.release(); throw error } // Every process the backend starts, the VCS client included, inherits // this token, so what it leaves behind can be ended when it is gone. let backendToken = UUID().uuidString - environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } + if prepared == nil { environment.merge(MacPluginBackendProcesses.environment(token: backendToken)) { _, token in token } } let session = LanguageServerRuntimeSession( providerID: "vcsProvider:\(target.pluginID):\(target.vcs)", executableURL: executableURL, @@ -216,16 +228,20 @@ final class MacVcsProviderSessions: VcsProviderRequesting { environment: environment, initializationOptions: provider.process.initializationOptions, initializeTimeout: 60, - core: core, + core: prepared?.owner.core ?? core, processRegistry: processRegistry, moduleID: .vcsProvidersExtension(target.pluginID), pluginID: target.pluginID ) let endBackendProcesses = endBackendProcesses - session.terminateProcess = { endBackendProcesses(backendToken, true) } - session.onReleased = { endBackendProcesses(backendToken, false) } + if let owner = prepared?.owner { session.releaseResources = owner.release } + else { + session.terminateProcess = { endBackendProcesses(backendToken, true) } + session.onReleased = { endBackendProcesses(backendToken, false) } + } entry.session = session - try await waitUntilReady(session, rootURL: target.workspaceURL) + do { try await waitUntilReady(session, rootURL: target.workspaceURL) } + catch { session.stop(); throw error } try Task.checkCancellation() guard running[target] === entry else { throw CancellationError() } // Core publishes the negotiation before the session is ready. A @@ -288,7 +304,10 @@ final class MacVcsProviderSessions: VcsProviderRequesting { var requirement: LanguageServerExecutableVersionRequirement? switch process.executable { case .file(let url): - return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + if remoteLaunches?.identity(workspaceURL) == nil { + return (url, runtime.languageToolProcessEnvironment(runtimeTools: process.runtimeTools)) + } + invocation = BuildInvocation(executables: [.setting(id: "pluginPackage", path: url.path)], runtimeDependencies: runtimes) case .commands(let commands): invocation = BuildInvocation(executables: commands.map { .command($0) }, runtimeDependencies: runtimes) case .runtime(let tool, let versionRequirement): diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 1427bac9c1..90b34554b5 100644 --- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -78,9 +78,13 @@ final class ProjectRuntimeService: ObservableObject { private var runtimeSessionID = UUID() private let javaSelector: any JavaRuntimeSelecting - private let runtimeLocator: any RuntimeLocator + private let localRuntimeLocator: any RuntimeLocator + private var runtimeLocator: any RuntimeLocator private let store: any KeyValueStore - private let toolDiscovery: any RuntimeToolDiscovery + private let localToolDiscovery: any RuntimeToolDiscovery + private var toolDiscovery: any RuntimeToolDiscovery + private let workspaceRuntime: ((URL) -> (any RuntimeLocator, any RuntimeToolDiscovery)?)? + private var usesWorkspaceRuntime = false private var discoveryTask: Task? private var activeDiscoveryID: UUID? private var javaLanguageServerRuntimePreparation: JavaLanguageServerRuntimePreparation = .unprepared @@ -93,12 +97,16 @@ final class ProjectRuntimeService: ObservableObject { runtimeLocator: any RuntimeLocator, store: any KeyValueStore, toolDiscovery: (any RuntimeToolDiscovery)? = nil, - javaSelector: any JavaRuntimeSelecting = RustCoreBridge() + javaSelector: any JavaRuntimeSelecting = RustCoreBridge(), + workspaceRuntime: ((URL) -> (any RuntimeLocator, any RuntimeToolDiscovery)?)? = nil ) { self.javaSelector = javaSelector self.runtimeLocator = runtimeLocator + self.localRuntimeLocator = runtimeLocator self.store = store self.toolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() + self.localToolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() + self.workspaceRuntime = workspaceRuntime } deinit { @@ -111,6 +119,10 @@ final class ProjectRuntimeService: ObservableObject { activeDiscoveryID = nil let normalizedURL = url.standardizedFileURL projectURL = normalizedURL + let selected = workspaceRuntime?(normalizedURL) + usesWorkspaceRuntime = selected != nil + runtimeLocator = selected?.0 ?? localRuntimeLocator + toolDiscovery = selected?.1 ?? localToolDiscovery javaRuntimes = [] mavenRuntimes = [] discoveredProjectURL = nil @@ -127,6 +139,9 @@ final class ProjectRuntimeService: ObservableObject { discoveryTask = nil activeDiscoveryID = nil projectURL = nil + runtimeLocator = localRuntimeLocator + toolDiscovery = localToolDiscovery + usesWorkspaceRuntime = false javaRuntimes = [] mavenRuntimes = [] discoveredProjectURL = nil @@ -153,9 +168,10 @@ final class ProjectRuntimeService: ObservableObject { /// JDKs for the New Project window, which can open without a project. /// Leaves the open project's discovery state untouched. func discoverJavaRuntimesForNewProject() async -> [JavaRuntimeCandidate] { - if hasDiscoveredRuntimes { return javaRuntimes } - let runtimeLocator = runtimeLocator - return await Task.detached(priority: .userInitiated) { runtimeLocator.discoverJavaRuntimes() }.value + let runtimeLocator = localRuntimeLocator + return await Self.backgroundProbe { cancelled in + runtimeLocator.discoverJavaRuntimes(isCancelled: cancelled) + } } func refreshAvailableRuntimes() async { @@ -179,10 +195,9 @@ final class ProjectRuntimeService: ObservableObject { } } let runtimeLocator = runtimeLocator - let result = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .utility).async { - continuation.resume(returning: runtimeLocator.discover()) - } + runtimeLocator.configureProbePaths([settings.javaHomePath, settings.mavenJavaHomePath, settings.mavenExecutableOverride]) + let result = await Self.backgroundProbe { cancelled in + runtimeLocator.discover(isCancelled: cancelled) } guard !Task.isCancelled, projectURL == targetProjectURL, @@ -190,6 +205,11 @@ final class ProjectRuntimeService: ObservableObject { javaRuntimes = result.javaRuntimes mavenRuntimes = result.mavenRuntimes discoveredProjectURL = targetProjectURL + if let message = runtimeLocator.discoveryFailure, let projectURL { + javaEnvironmentReport = JavaEnvironmentReport(status: .probeFailed(message: message), + projectURL: projectURL, javaHomePath: nil, javaExecutablePath: nil) + return + } refreshJavaEnvironmentReport(using: result.javaRuntimes) isDiscovering = false } @@ -235,7 +255,7 @@ final class ProjectRuntimeService: ObservableObject { // fast path. Unknown versions never satisfy a real minimum, so only // an unconstrained result may bypass discovery here. let candidate = AutomaticJavaCandidate(id: environmentHome.path, version: "", priority: 0) - switch javaSelector.selectJavaRuntime(at: projectURL, candidates: [candidate], fallbackID: candidate.id) { + switch selectJavaRuntime(candidates: [candidate], fallbackID: candidate.id) { case .failure(let error): return .unavailable(error.message) case .success(let selection) where selection.warning == nil: return .found(environmentHome, .javaHomeEnvironment) @@ -257,7 +277,7 @@ final class ProjectRuntimeService: ObservableObject { candidates.append(AutomaticJavaCandidate(id: home.path, version: "", priority: 0)) } let fallback = environmentHome?.path ?? candidates.first?.id - switch javaSelector.selectJavaRuntime(at: projectURL, candidates: candidates, fallbackID: fallback) { + switch selectJavaRuntime(candidates: candidates, fallbackID: fallback) { case .failure(let error): return .unavailable(error.message) case .success(let selection): guard let id = selection.id else { return .notFound } @@ -268,6 +288,13 @@ final class ProjectRuntimeService: ObservableObject { } } + private func selectJavaRuntime( + candidates: [AutomaticJavaCandidate], fallbackID: String? + ) -> Result { + runtimeLocator.selectJavaRuntime(candidates: candidates, fallbackID: fallbackID) + ?? javaSelector.selectJavaRuntime(at: projectURL, candidates: candidates, fallbackID: fallbackID) + } + func javaExecutableURL(overridePath: String? = nil) -> URL? { javaHomeURL(overridePath: overridePath)?.appendingPathComponent("bin/java") } @@ -513,9 +540,7 @@ final class ProjectRuntimeService: ObservableObject { } func executableURL(at path: String) -> URL? { - let normalized = (path as NSString) - .expandingTildeInPath - .trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = normalizedOverridePath(path) guard !normalized.isEmpty else { return nil } let url = URL(fileURLWithPath: normalized).standardizedFileURL return runtimeLocator.isExecutable(at: url) ? url : nil @@ -534,9 +559,7 @@ final class ProjectRuntimeService: ObservableObject { func chooseMavenExecutable(at rootURL: URL, overridePath: String? = nil) -> RuntimeChoice { let configured = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !configured.isEmpty { - let resolved = configured.hasPrefix("/") - ? URL(fileURLWithPath: configured) - : rootURL.appendingPathComponent(configured) + let resolved = URL(fileURLWithPath: runtimeLocator.normalizedPath(configured, relativeTo: rootURL)) let standardized = resolved.standardizedFileURL if runtimeLocator.isExecutable(at: standardized) { return .found(standardized, .configured) @@ -686,7 +709,14 @@ final class ProjectRuntimeService: ObservableObject { func updateSettings(_ settings: ProjectRuntimeSettings) { self.settings = settings + runtimeLocator.configureProbePaths([settings.javaHomePath, settings.mavenJavaHomePath, settings.mavenExecutableOverride]) persistSettings() + if usesWorkspaceRuntime { + discoveredProjectURL = nil + discoveryTask?.cancel() + discoveryTask = Task { [weak self] in await self?.performRuntimeRefresh() } + return + } if !javaRuntimes.isEmpty { refreshJavaEnvironmentReport(using: javaRuntimes) } @@ -791,14 +821,11 @@ final class ProjectRuntimeService: ObservableObject { private func normalizedOverridePath(_ path: String) -> String { let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedPath.isEmpty else { return "" } - let normalized = normalizedPath(trimmedPath) - guard !(normalized as NSString).isAbsolutePath, - let projectURL else { return normalized } - return projectURL.appendingPathComponent(normalized).standardizedFileURL.path + return runtimeLocator.normalizedPath(trimmedPath, relativeTo: projectURL) } private func normalizedPath(_ path: String) -> String { - ((path as NSString).expandingTildeInPath as NSString).standardizingPath + runtimeLocator.normalizedPath(path, relativeTo: nil) } } diff --git a/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift new file mode 100644 index 0000000000..b4453ba8dd --- /dev/null +++ b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift @@ -0,0 +1,133 @@ +import Foundation +import LitheCoreContracts + +/// Owns host subscriptions independently of the UI executor. A generation +/// prevents late events from a cancelled or replaced subscription refreshing +/// the next workspace. +final class RemoteDirectoryObservation: DirectoryChangeSource, @unchecked Sendable { + private let lock = NSRecursiveLock() + private let configuration: DirectoryWatchConfiguration + private let visibilityRules: FileVisibilityRules + private let binding: RemoteWorkspaceBinding + private let registry: RemoteWorkspaceRegistry + private let transport: any RemoteWorkspaceTransport + private let onChange: @Sendable (DirectoryChangeBatch) -> Void + private var tasks: [Task] = [] + private var generation = UUID() + + init( + configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, + binding: RemoteWorkspaceBinding, registry: RemoteWorkspaceRegistry, + transport: any RemoteWorkspaceTransport, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) { + self.configuration = configuration + self.visibilityRules = visibilityRules + self.binding = binding + self.registry = registry + self.transport = transport + self.onChange = onChange + } + + func start() { + lock.withLock { + stop() + let current = generation + for root in configuration.physicalRoots { + // Git common directories may live outside the workspace. The + // Core Git engine already returns those as absolute host paths. + let remoteRoot = registry.resolve(root)?.remotePath ?? root.path + let transport = transport + let session = binding.sessionID + tasks.append(Task { [weak self] in + do { + try await transport.watch(root: remoteRoot, session: session) { [weak self] event in + self?.receive(event, generation: current) + } + } catch { + guard !Task.isCancelled else { return } + NSLog("Remote directory watch ended: %@", String(describing: type(of: error))) + self?.receive(.invalidated(rootRemoved: false), generation: current) + } + }) + } + } + } + + func stop() { + lock.withLock { + generation = UUID() + tasks.forEach { $0.cancel() } + tasks.removeAll() + } + } + + private func receive(_ event: RemoteWatchEvent, generation: UUID) { + lock.withLock { + guard self.generation == generation else { return } + var batch = DirectoryChangeBatch() + switch event { + case .started: + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + case .invalidated(let rootRemoved): + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + batch.watchRootsChanged = rootRemoved + case .changed(let paths, let overflow): + batch.requiresFullRescan = overflow + batch.gitStateMayHaveChanged = overflow + var workspacePaths = Set() + for path in paths { + let hostRoot = binding.remoteRoot.hasSuffix("/") ? binding.remoteRoot : binding.remoteRoot + "/" + let url: URL + if path == binding.remoteRoot { url = binding.root } + else if path.hasPrefix(hostRoot) { + url = binding.root.appendingPathComponent(String(path.dropFirst(hostRoot.count))).standardizedFileURL + } else { url = URL(fileURLWithPath: path).standardizedFileURL } + if configuration.revalidatesIgnoredDirectory(url, isStructuralChange: true) { + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + } + if configuration.isIgnored(url) { continue } + if configuration.isGitContextPointer(url) { + batch.gitStateMayHaveChanged = true + batch.watchRootsChanged = true + } else if configuration.containsGitMetadataPath(url) { + batch.gitStateMayHaveChanged = true + } else if configuration.containsWorkspacePath(url) { + batch.gitStateMayHaveChanged = true + if !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) + || visibilityRules.isWatchedDespiteHiding(url, relativeTo: configuration.workspaceRoot) { + workspacePaths.insert(url.path) + } + } else if configuration.containsRepositoryPath(url) { + batch.gitStateMayHaveChanged = true + } + } + batch.workspacePaths = workspacePaths.sorted() + } + if !batch.isEmpty { onChange(batch) } + } + } + + deinit { stop() } +} + +/// The editor owns both local and remote observers through its existing port. +final class RemoteDocumentObservations: DocumentFileObservation, @unchecked Sendable { + private let local: any DocumentFileObservation + private let remote: [RemoteDirectoryObservation] + + init(local: any DocumentFileObservation, remote: [RemoteDirectoryObservation]) { + self.local = local + self.remote = remote + } + + func cancel() { + local.cancel() + remote.forEach { $0.stop() } + } + + deinit { cancel() } +} diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index 82364637b2..6fe5502083 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -53,7 +53,7 @@ final class RemoteWorkspaceRegistry: @unchecked Sendable { func resolve(_ url: URL) -> (binding: RemoteWorkspaceBinding, remotePath: String)? { let path = url.standardizedFileURL.path return lock.withLock { - for (root, binding) in bindings { + for (root, binding) in bindings.sorted(by: { $0.key.count > $1.key.count }) { if path == root { return (binding, binding.remoteRoot) } let prefix = root.hasSuffix("/") ? root : root + "/" guard path.hasPrefix(prefix) else { continue } @@ -101,6 +101,18 @@ struct ConnectionWatchingTransport: RemoteWorkspaceTransport { try watching(id) { try base.writeFile(data, to: path, expected: expected, session: id) } } + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + try watching(session) { try base.mutate(mutation, session: session) } + } + + func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + do { + try await base.watch(root: root, session: session, onEvent: onEvent) + } catch { + try watching(session) { throw error } + } + } + private func watching(_ id: String, _ work: () throws -> Value) throws -> Value { do { return try work() @@ -158,17 +170,14 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { } func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { - guard !registry.contains(rootURL) else { return } local.warmSearchIndex(at: rootURL, visibilityRules: visibilityRules) } func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) { - guard !registry.contains(rootURL) else { return } local.updateSearchIndex(at: rootURL, changedPaths: changedPaths, visibilityRules: visibilityRules) } func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { - guard !registry.contains(rootURL) else { return } local.invalidateSearchIndex(at: rootURL, visibilityRules: visibilityRules) } @@ -183,11 +192,17 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { let url = rootURL.appendingPathComponent(relativePath) - guard registry.contains(url) else { + guard let (binding, path) = registry.resolve(url) else { return local.writeFile(text, at: rootURL, relativePath: relativePath) } - // This legacy entry point has no version to check, and remote files - // cannot be created yet, so it never writes. + do { + let expected: RemoteFileVersion? + do { expected = try transport.readFile(path, session: binding.sessionID).version } + catch let failure as RemoteProbeFailure where failure.code == "workspace_not_found" { expected = nil } + if case .saved = try transport.writeFile(Data(text.utf8), to: path, expected: expected, session: binding.sessionID) { + return true + } + } catch { return false } return false } @@ -229,8 +244,8 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen private var versionOrder: [String] = [] private static let rememberedVersions = 512 /// Parent listings used to answer existence checks, so a burst of - /// `fileExists` calls costs one round trip; short-lived because remote - /// files are not watched. Writes drop them. + /// `fileExists` calls costs one round trip. Mutations and observed external + /// changes drop them; the short lifetime also covers inactive observers. private var listings: [String: (entries: [RemoteDirectoryEntry], at: Date)] = [:] private static let listingLifetime: TimeInterval = 3 @@ -273,11 +288,8 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen guard encoding == .utf8 || encoding == .utf8Bom else { throw CocoaError(.fileWriteInapplicableStringEncoding) } var data = Data(text.utf8) if encoding == .utf8Bom { data.insert(contentsOf: [0xEF, 0xBB, 0xBF], at: 0) } - // Without a version the host would create the file; remote files - // cannot be created yet. - guard let expectedIdentity else { throw CocoaError(.featureUnsupported) } - var expected = version(for: versionKey(binding, path, expectedIdentity)) - if expected == nil { + var expected = expectedIdentity.flatMap { version(for: versionKey(binding, path, $0)) } + if let expectedIdentity, expected == nil { // Opened before this session: compare the current bytes. let current = try transport.readFile(path, session: binding.sessionID) guard Self.identity(of: current.data) == expectedIdentity else { @@ -358,34 +370,50 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen } func createFile(at url: URL) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + guard url.standardizedFileURL != binding.root.standardizedFileURL else { throw CocoaError(.fileWriteFileExists) } + lock.withLock { listings.removeAll() } + guard case .saved = try transport.writeFile(Data(), to: path, expected: nil, session: binding.sessionID) else { + throw CocoaError(.fileWriteFileExists) + } + return + } try local.createFile(at: url) } func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + try mutate(.createDirectory(path: path, recursive: withIntermediateDirectories), binding: binding) + return + } try local.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories) } func copyItem(at sourceURL: URL, to destinationURL: URL) throws { - try remoteOnly(sourceURL) - try remoteOnly(destinationURL) + if try transfer(sourceURL, destinationURL, copying: true) { return } try local.copyItem(at: sourceURL, to: destinationURL) } func moveItem(at sourceURL: URL, to destinationURL: URL) throws { - try remoteOnly(sourceURL) - try remoteOnly(destinationURL) + if try transfer(sourceURL, destinationURL, copying: false) { return } try local.moveItem(at: sourceURL, to: destinationURL) } func removeItem(at url: URL) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + try requireNonRoot(url, binding: binding) + try mutate(.remove(path: path), binding: binding) + return + } try local.removeItem(at: url) } func trashItem(at url: URL) throws { - try remoteOnly(url) + if let (binding, path) = registry.resolve(url) { + try requireNonRoot(url, binding: binding) + try mutate(.trash(path: path), binding: binding) + return + } try local.trashItem(at: url) } @@ -396,7 +424,12 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen func writeText(_ text: String, to url: URL) throws { guard registry.contains(url) else { return try local.writeText(text, to: url) } - _ = try remoteWrite(text, to: url, encoding: .utf8, expectedIdentity: nil) + let identity: String? + do { identity = try remoteRead(url)?.identity } + catch let error as CocoaError where error.code == .fileReadNoSuchFile { identity = nil } + if case .conflict = try remoteWrite(text, to: url, encoding: .utf8, expectedIdentity: identity) { + throw CocoaError(.fileWriteFileExists) + } } func readText(from url: URL) throws -> String { @@ -405,9 +438,47 @@ final class RemoteRoutingFileOperations: WorkspaceFileOperations, @unchecked Sen } func observeDocuments(at urls: [URL], onChange: @escaping @Sendable ([URL]) -> Void) -> any DocumentFileObservation { - // Remote files are not watched yet; a changed file is detected as a - // conflict when it is saved. - local.observeDocuments(at: urls.filter { !registry.contains($0) }, onChange: onChange) + let localObservation = local.observeDocuments(at: urls.filter { !registry.contains($0) }, onChange: onChange) + let groups = Dictionary(grouping: urls.filter { registry.contains($0) }) { registry.resolve($0)!.binding.root } + let observations = groups.compactMap { root, documents -> RemoteDirectoryObservation? in + guard let binding = registry.binding(forRoot: root) else { return nil } + let observation = RemoteDirectoryObservation( + configuration: DirectoryWatchConfiguration(workspaceRoot: root, gitContext: nil), + visibilityRules: FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []), + binding: binding, registry: registry, transport: transport + ) { [weak self] batch in + self?.lock.withLock { self?.listings.removeAll() } + let affected = batch.requiresFullRescan ? documents : documents.filter { document in + batch.workspacePaths.contains { path in document.path == path || document.path.hasPrefix(path + "/") } + } + if !affected.isEmpty { onChange(affected) } + } + observation.start() + return observation + } + return RemoteDocumentObservations(local: localObservation, remote: observations) + } + + private func requireNonRoot(_ url: URL, binding: RemoteWorkspaceBinding) throws { + guard url.standardizedFileURL != binding.root.standardizedFileURL else { throw CocoaError(.fileWriteNoPermission) } + } + + private func mutate(_ mutation: RemoteFileMutation, binding: RemoteWorkspaceBinding) throws { + defer { lock.withLock { listings.removeAll() } } + try transport.mutate(mutation, session: binding.sessionID) + } + + private func transfer(_ source: URL, _ destination: URL, copying: Bool) throws -> Bool { + let from = registry.resolve(source) + let to = registry.resolve(destination) + guard from != nil || to != nil else { return false } + guard let from, let to, from.binding.sessionID == to.binding.sessionID else { + throw CocoaError(.featureUnsupported) + } + try requireNonRoot(source, binding: from.binding) + try requireNonRoot(destination, binding: to.binding) + try mutate(copying ? .copy(from: from.remotePath, to: to.remotePath) : .move(from: from.remotePath, to: to.remotePath), binding: from.binding) + return true } func readDocumentText(from url: URL) throws -> String? { diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index a189aa48d6..97122eceee 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -24,6 +24,7 @@ struct TerminalView: View { ) .litheWorkbenchSurface(LitheTheme.editor) .background(LitheToolWindowActivityTracker(isActive: $terminalToolActive)) + .task(id: model.workspaceURL) { feature.setShellWorkspace(model.workspaceURL) } .onAppear { terminalToolActive = (model.activeToolTerminalSession?.nativeView as? LitheTerminalView)?.hasFocus == true } @@ -240,8 +241,11 @@ struct TerminalView: View { } items += [ .separator, - .action("Detect Installed Shells") { feature.refreshAvailableShells() } + .action("Detect Installed Shells", isEnabled: !feature.isDiscoveringShells) { feature.refreshAvailableShells() } ] + if let error = feature.shellDiscoveryError { + items.append(.action(error, localizesTitle: false, isEnabled: false, action: {})) + } return items } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index 84f5384aa5..3541b61b0f 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -181,9 +181,12 @@ package struct BuildSystemPluginSource: Equatable, Sendable { package struct BuildSystemTarget: Equatable, Sendable { package let manifest: Data package let buildSystemID: String + /// Retains the owning host even for pure plans with only relative paths. + package let workspaceURL: URL? - package init(manifest: Data, buildSystemID: String) { + package init(manifest: Data, buildSystemID: String, workspaceURL: URL? = nil) { self.manifest = manifest; self.buildSystemID = buildSystemID + self.workspaceURL = workspaceURL } } @@ -519,10 +522,15 @@ package protocol BuildToolResolving: AnyObject { ) async throws -> ResolvedBuildTool /// Passive discovery for project import; must never probe or execute tools. func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? + /// Host discovery may require I/O; project import still never runs code. + func prepareLanguageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) async throws -> URL? } package extension BuildToolResolving { func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? { nil } + func prepareLanguageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) async throws -> URL? { + languageImportExecutable(invocation, workspaceURL: workspaceURL) + } } // MARK: Options @@ -1066,6 +1074,36 @@ package protocol BuildSystemStateLocating: Sendable { /// A fresh, not yet existing path for one `${outputFile}`. func allocateOutputFile(in workspaceStateDirectory: URL) throws -> URL func removeOutputFile(_ url: URL) + /// Resolves host-owned paths before Core expands command templates. The + /// returned cleanup retains that host even after a workspace reconnects. + func prepareRun(pluginID: String, buildSystemID: String, packageURL: URL?, + workspaceURL: URL, allocatesOutputFile: Bool) async throws -> BuildSystemRunResources +} + +package struct BuildSystemRunResources: Sendable { + package let pluginDirectory: URL? + package let workspaceStateDirectory: URL + package let outputFile: URL? + package let release: @Sendable () async -> Void + + package init(pluginDirectory: URL?, workspaceStateDirectory: URL, outputFile: URL?, + release: @escaping @Sendable () async -> Void) { + self.pluginDirectory = pluginDirectory + self.workspaceStateDirectory = workspaceStateDirectory + self.outputFile = outputFile + self.release = release + } +} + +extension BuildSystemStateLocating { + package func prepareRun(pluginID: String, buildSystemID: String, packageURL: URL?, + workspaceURL: URL, allocatesOutputFile: Bool) async throws -> BuildSystemRunResources { + let directory = try workspaceStateDirectory(pluginID: pluginID, buildSystemID: buildSystemID, workspaceURL: workspaceURL) + let output = try allocatesOutputFile ? allocateOutputFile(in: directory) : nil + return BuildSystemRunResources(pluginDirectory: packageURL, workspaceStateDirectory: directory, outputFile: output) { + if let output { removeOutputFile(output) } + } + } } // MARK: Processes diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 6b5633a847..cfb27725f5 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -358,6 +358,9 @@ package struct JavaLanguageImportConfiguration: Equatable, Sendable { } package protocol LanguageServerRuntimeCore: Sendable { + /// Network-backed startup must run off the main actor. This is resolved + /// from the workspace owner, before any process is created. + func requiresBackgroundLanguageServerStart(rootURL: URL) -> Bool func startLanguageServer( providerID: String, executableURL: URL, @@ -551,6 +554,8 @@ package protocol LanguageServerRuntimeCore: Sendable { } package extension LanguageServerRuntimeCore { + func requiresBackgroundLanguageServerStart(rootURL _: URL) -> Bool { false } + func startLanguageServer( providerID: String, executableURL: URL, arguments: [String], environment: [String: String], rootURL: URL, workingDirectoryURL: URL, initializationOptions: ToolingJSONValue?, diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 308aa79c84..a77d05cdcb 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -1162,6 +1162,7 @@ package protocol LanguageProviderRuntime: AnyObject { var supportsLanguageServerSession: Bool { get } var unavailableToolingMessage: String? { get } func makeLanguageServerSession() -> (any LanguageServerSession)? + func makeLanguageServerSession(rootURL: URL) -> (any LanguageServerSession)? } @MainActor @@ -1188,4 +1189,5 @@ package extension LanguageProviderRuntime { var supportsLanguageServerSession: Bool { false } var unavailableToolingMessage: String? { nil } func makeLanguageServerSession() -> (any LanguageServerSession)? { nil } + func makeLanguageServerSession(rootURL: URL) -> (any LanguageServerSession)? { makeLanguageServerSession() } } diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index f317401eef..b2ac41f1b0 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -43,6 +43,7 @@ package struct BuildSystemEntry: Equatable, Identifiable, Sendable { package let instance: BuildSystemInstance package let declaration: DeclarativeBuildSystem package let source: BuildSystemPluginSource + package let workspaceURL: URL? package var model: BuildModel? package var modelState: BuildModelState package var options: [BuildResolvedOption] @@ -55,13 +56,14 @@ package struct BuildSystemEntry: Equatable, Identifiable, Sendable { package var id: String { instance.id } package var target: BuildSystemTarget { - BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID) + BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID, workspaceURL: workspaceURL) } package init( instance: BuildSystemInstance, declaration: DeclarativeBuildSystem, source: BuildSystemPluginSource, + workspaceURL: URL? = nil, model: BuildModel? = nil, modelState: BuildModelState = .notLoaded, options: [BuildResolvedOption] = [], @@ -70,6 +72,7 @@ package struct BuildSystemEntry: Equatable, Identifiable, Sendable { dependencies: [String: BuildDependencyState] = [:] ) { self.instance = instance; self.declaration = declaration; self.source = source + self.workspaceURL = workspaceURL self.model = model; self.modelState = modelState; self.options = options; self.sync = sync self.isReloadRequired = isReloadRequired; self.dependencies = dependencies } @@ -340,7 +343,16 @@ package final class BuildSystemService: ObservableObject { settings: self.settings(input.entry.source.pluginID) ) else { continue } - executables[input.entry.id] = self.tools.languageImportExecutable(invocation, workspaceURL: workspaceURL) + do { + executables[input.entry.id] = try await self.tools.prepareLanguageImportExecutable(invocation, workspaceURL: workspaceURL) + } catch { + guard self.generation == generation, self.detectionID == detectionID, + !Task.isCancelled else { return } + self.failLanguageImport(error) + return + } + guard self.generation == generation, self.detectionID == detectionID, + !Task.isCancelled else { return } } let withExecutables = executables let configured = await Task.detached(priority: .userInitiated) { @@ -602,9 +614,7 @@ package final class BuildSystemService: ObservableObject { owner: CommandOwner ) async -> Result { do { - let context = try planContext(entry, workspaceURL: workspaceURL) - let outputFile = try context.workspaceStateDirectory.map { try stateLocator.allocateOutputFile(in: $0) } - defer { if let outputFile { stateLocator.removeOutputFile(outputFile) } } + return .success(try await withPlanContext(entry, workspaceURL: workspaceURL, allocatesOutputFile: true) { context, outputFile in let planner = planner let plan = try await outputPlan(entry, workspaceURL: workspaceURL, reason: reason, owner: owner) { hints in try planner.modelPlan(entry.target, context: context, outputFile: outputFile, providerHints: hints) @@ -621,7 +631,7 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + return try await Task.detached(priority: .userInitiated) { let model = try planner.decodeModel( target, workspaceURL: workspaceURL, root: root, output: output, options: context.options ) @@ -635,7 +645,8 @@ package final class BuildSystemService: ObservableObject { projects: model.projects.map(\.path), options: context.options ) ) - }.value) + }.value + }) } catch { return .failure(error) } @@ -648,7 +659,7 @@ package final class BuildSystemService: ObservableObject { package func runTask(_ entryID: String, request: BuildTaskRequest, title: String) -> UUID? { guard let workspaceURL, let entry = entry(entryID) else { return nil } return startRun(entryID, title: title) { service, run in - let context = try service.planContext(entry, workspaceURL: workspaceURL) + try await service.withPlanContext(entry, workspaceURL: workspaceURL) { context, _ in let plan = try service.planner.taskPlan(entry.target, context: context, request: request) let collected = try await service.runStreamedCommand( plan, entry: entry, workspaceURL: workspaceURL, owner: .run(run) @@ -659,6 +670,7 @@ package final class BuildSystemService: ObservableObject { guard service.isCurrent(run) else { throw CancellationError() } service.diagnostics = diagnostics return collected.exitCode + } } } @@ -666,7 +678,7 @@ package final class BuildSystemService: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { guard let workspaceURL, let entry = entry(entryID) else { return nil } return startRun(entryID, title: title) { service, run in - let context = try service.planContext(entry, workspaceURL: workspaceURL) + try await service.withPlanContext(entry, workspaceURL: workspaceURL) { context, _ in let plan = try service.planner.testPlan(entry.target, context: context, request: request) if plan.results != nil, request.project == nil, entry.instance.modelSource != nil, @@ -714,6 +726,7 @@ package final class BuildSystemService: ObservableObject { } } return collected.exitCode + } } } @@ -905,9 +918,7 @@ package final class BuildSystemService: ObservableObject { owner: CommandOwner ) async -> Result { do { - let context = try planContext(entry, workspaceURL: workspaceURL) - let outputFile = try context.workspaceStateDirectory.map { try stateLocator.allocateOutputFile(in: $0) } - defer { if let outputFile { stateLocator.removeOutputFile(outputFile) } } + return .success(try await withPlanContext(entry, workspaceURL: workspaceURL, allocatesOutputFile: true) { context, outputFile in let planner = planner let selection = project.map(BuildSelection.init(project:)) let plan = try await outputPlan(entry, workspaceURL: workspaceURL, reason: reason, owner: owner) { hints in @@ -927,11 +938,12 @@ package final class BuildSystemService: ObservableObject { ) let target = entry.target let root = entry.instance.root - return .success(try await Task.detached(priority: .userInitiated) { + return try await Task.detached(priority: .userInitiated) { try planner.decodeDependencies( target, workspaceURL: workspaceURL, root: root, project: project?.path, output: output ) - }.value) + }.value + }) } catch { return .failure(error) } @@ -1156,23 +1168,35 @@ package final class BuildSystemService: ObservableObject { ))?.fingerprint } - private func planContext(_ entry: BuildSystemEntry, workspaceURL: URL) throws -> BuildPlanContext { + private func withPlanContext( + _ entry: BuildSystemEntry, workspaceURL: URL, allocatesOutputFile: Bool = false, + perform: (BuildPlanContext, URL?) async throws -> Value + ) async throws -> Value { let stored = storedOptions(for: entry, workspaceURL: workspaceURL) let resolution = try? planner.resolveOptions( entry.target, options: stored, modelValues: entry.model?.optionValues ) - return BuildPlanContext( + let resources = try await stateLocator.prepareRun( + pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, + packageURL: entry.source.packageURL, workspaceURL: workspaceURL, allocatesOutputFile: allocatesOutputFile + ) + let context = BuildPlanContext( root: entry.instance.root, // A plan gets only values Core still accepts for the current // model; otherwise a removed Cargo feature would reach the argv. options: Self.effectiveOptionValues(resolution, stored: stored), - pluginDirectory: entry.source.packageURL, - workspaceStateDirectory: try stateLocator.workspaceStateDirectory( - pluginID: entry.instance.pluginID, - buildSystemID: entry.instance.buildSystemID, - workspaceURL: workspaceURL - ) + pluginDirectory: resources.pluginDirectory, + workspaceStateDirectory: resources.workspaceStateDirectory ) + do { + try Task.checkCancellation() + let value = try await perform(context, resources.outputFile) + await resources.release() + return value + } catch { + await resources.release() + throw error + } } /// Stored values reduced to the ones Core accepted in `resolution`. Core @@ -1490,7 +1514,7 @@ package final class BuildSystemService: ObservableObject { let declaration = source.buildSystems.first(where: { $0.id == instance.buildSystemID }) else { return nil } let stored = storedOptions[instance.buildSystemID] ?? [:] - let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID) + let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID, workspaceURL: workspaceURL) let earlier = previous[instance.id] // The instance ID is the build-system ID plus its root, so another // plugin can take the same ID over. Its model and dependencies are @@ -1529,6 +1553,7 @@ package final class BuildSystemService: ObservableObject { instance: instance, declaration: declaration, source: source, + workspaceURL: workspaceURL, model: model, modelState: modelState, options: resolution?.options ?? [], diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift index 717b65ad21..464bc1a8c5 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift @@ -22,6 +22,29 @@ package struct ResolvedLanguageServerLaunch: Equatable, Sendable { } } +/// An asynchronous host adapter supplies a complete process and its lifetime +/// before startup. Network work must finish before Core starts the process. +package struct PreparedLanguageServerProcess: Sendable { + package let executableURL: URL + package let environment: [String: String] + package let launch: ResolvedLanguageServerLaunch + package let core: any LanguageServerRuntimeCore + package let release: @Sendable () async -> Void + + package init(executableURL: URL, environment: [String: String], launch: ResolvedLanguageServerLaunch, + core: any LanguageServerRuntimeCore, release: @escaping @Sendable () async -> Void) { + self.executableURL = executableURL + self.environment = environment + self.launch = launch + self.core = core + self.release = release + } +} + +package typealias WorkspaceLanguageServerSessionFactory = @MainActor ( + LanguageServerLaunchDescriptor, URL +) -> (any LanguageServerSession)? + /// Resolves a declarative launch for one workspace root. It runs when the /// session starts, because the workspace state directory depends on the root. package typealias LanguageServerLaunchResolver = @MainActor ( @@ -39,6 +62,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { private let hostIntegrationResolver: LanguageServerHostIntegrationResolver? private let languageServerLaunchResolver: LanguageServerLaunchResolver? private let languageServerCacheDirectory: URL? + private let workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID private var runtimeUnavailableMessage: String? @@ -62,6 +86,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { hostIntegrationResolver: LanguageServerHostIntegrationResolver? = nil, languageServerLaunchResolver: LanguageServerLaunchResolver? = nil, languageServerCacheDirectory: URL? = nil, + workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence ) { @@ -73,6 +98,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { self.hostIntegrationResolver = hostIntegrationResolver self.languageServerLaunchResolver = languageServerLaunchResolver self.languageServerCacheDirectory = languageServerCacheDirectory + self.workspaceSessionFactory = workspaceSessionFactory self.processRegistry = processRegistry self.moduleID = moduleID } @@ -142,6 +168,14 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { ) } + package func makeLanguageServerSession(rootURL: URL) -> (any LanguageServerSession)? { + if languageServerCore.requiresBackgroundLanguageServerStart(rootURL: rootURL), + let workspaceSessionFactory, let languageServerLaunch { + return workspaceSessionFactory(languageServerLaunch, rootURL) + } + return makeLanguageServerSession() + } + } @MainActor @@ -152,6 +186,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime private let hostIntegrationResolver: LanguageServerHostIntegrationResolver? private let languageServerLaunchResolver: LanguageServerLaunchResolver? private let languageServerCacheDirectory: URL? + private let workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID @@ -162,6 +197,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime hostIntegrationResolver: LanguageServerHostIntegrationResolver? = nil, languageServerLaunchResolver: LanguageServerLaunchResolver? = nil, languageServerCacheDirectory: URL? = nil, + workspaceSessionFactory: WorkspaceLanguageServerSessionFactory? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence ) { @@ -171,6 +207,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime self.hostIntegrationResolver = hostIntegrationResolver self.languageServerLaunchResolver = languageServerLaunchResolver self.languageServerCacheDirectory = languageServerCacheDirectory + self.workspaceSessionFactory = workspaceSessionFactory self.processRegistry = processRegistry self.moduleID = moduleID } @@ -191,6 +228,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime hostIntegrationResolver: hostIntegrationResolver, languageServerLaunchResolver: languageServerLaunchResolver, languageServerCacheDirectory: languageServerCacheDirectory, + workspaceSessionFactory: workspaceSessionFactory, processRegistry: processRegistry, moduleID: moduleID ) @@ -210,6 +248,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime hostIntegrationResolver: hostIntegrationResolver, languageServerLaunchResolver: languageServerLaunchResolver, languageServerCacheDirectory: languageServerCacheDirectory, + workspaceSessionFactory: workspaceSessionFactory, processRegistry: processRegistry, moduleID: ownerModuleID ) diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 3f65d39748..7747dfb716 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -29,17 +29,21 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private static let idlePollNanoseconds: UInt64 = 50_000_000 private let providerID: String - private let executableURL: URL - private let arguments: [String] - private let environment: [String: String] - private let initializationOptions: ToolingJSONValue? + private var executableURL: URL + private var arguments: [String] + private var environment: [String: String] + private var initializationOptions: ToolingJSONValue? + private var preparedWorkspaceConfiguration: ToolingJSONValue? + private let prepareProcess: (@MainActor (URL) async throws -> PreparedLanguageServerProcess)? + private var preparationTask: Task? + private var startingPreparedProcess = false private let launchResolver: (@MainActor (URL) throws -> ResolvedLanguageServerLaunch)? private let hostIntegrationLaunch: LanguageServerHostIntegrationLaunch? private let cacheDirectoryURL: URL? private let initializeTimeout: TimeInterval private let requestTimeout: TimeInterval private let shutdownTimeout: TimeInterval - private let core: any LanguageServerRuntimeCore + private var core: any LanguageServerRuntimeCore private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID /// The plugin that owns the process; nil for host-owned servers, which @@ -49,6 +53,18 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private let hostServices: [String] private var sessionID: String? + private var startupGeneration: UUID? + private var usesBackgroundStart = false + private struct StartingDocument { + let text: String + let languageID: String + } + /// Latest full snapshots replace incremental edits while the remote + /// process is starting. They are flushed once, before polling its events. + private var startingDocuments: [URL: StartingDocument] = [:] + private var startingDocumentBytes = 0 + private var startingFileChanges: [LanguageServerWorkspaceFileChange] = [] + private static let startingDocumentByteLimit = 8 * 1024 * 1024 private var pendingOperations: [String: PendingOperation] = [:] /// Progress listeners of running plugin functions, by operation ID. private var functionProgress: [String: @MainActor @Sendable (PluginFunctionProgress) -> Void] = [:] @@ -78,6 +94,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Called once the session has ended and handed its process back to /// Core, so the owner can end what the process left running. package var onReleased: (@MainActor () -> Void)? + /// Package snapshots may be removed only after Core has ended the process. + package var releaseResources: (@Sendable () async -> Void)? package var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? package var onLog: ((LanguageServerLogLevel, String, String?, String?) -> Void)? @@ -111,6 +129,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { arguments: [String], environment: [String: String], initializationOptions: ToolingJSONValue? = nil, + prepareProcess: (@MainActor (URL) async throws -> PreparedLanguageServerProcess)? = nil, launchResolver: (@MainActor (URL) throws -> ResolvedLanguageServerLaunch)? = nil, hostIntegrationLaunch: LanguageServerHostIntegrationLaunch? = nil, cacheDirectoryURL: URL? = nil, @@ -128,6 +147,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.arguments = arguments self.environment = environment self.initializationOptions = initializationOptions + self.prepareProcess = prepareProcess self.launchResolver = launchResolver self.hostIntegrationLaunch = hostIntegrationLaunch self.cacheDirectoryURL = cacheDirectoryURL @@ -144,6 +164,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Derived from the last lifecycle state Rust published: there is no local /// process handle to ask. package var isRunning: Bool { + if startupGeneration != nil { return true } guard sessionID != nil else { return false } switch state { case .stopped, .failed: @@ -171,12 +192,54 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { mavenContext: MavenLaunchContext?, javaGradle: JavaGradleImportConfiguration? ) throws { - guard sessionID == nil else { return } + guard sessionID == nil, startupGeneration == nil else { return } let normalizedRoot = rootURL.standardizedFileURL transition(to: .startingProcess) + if let prepareProcess, !startingPreparedProcess { + let generation = UUID() + startupGeneration = generation + preparationTask = Task { @MainActor [weak self] in + do { + let prepared = try await prepareProcess(normalizedRoot) + guard let self, self.startupGeneration == generation, !Task.isCancelled else { + await prepared.release() + return + } + self.preparationTask = nil + self.startupGeneration = nil + self.executableURL = prepared.executableURL + self.arguments = prepared.launch.arguments + self.environment = prepared.environment + self.initializationOptions = prepared.launch.initializationOptions + self.preparedWorkspaceConfiguration = prepared.launch.workspaceConfiguration + self.core = prepared.core + self.releaseResources = prepared.release + self.startingPreparedProcess = true + defer { self.startingPreparedProcess = false } + do { + try self.start(rootURL: normalizedRoot, workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, javaGradle: javaGradle) + } catch { + self.releaseSession() + } + } catch { + guard let self, self.startupGeneration == generation else { return } + self.preparationTask = nil + self.startupGeneration = nil + self.startingDocuments = [:] + self.startingDocumentBytes = 0 + self.startingFileChanges = [] + let failure = LanguageServerSessionFailure(code: (error as? DeclarativePluginRejection)?.code ?? "plugin.launch.failed", + stage: "processStart", message: error.localizedDescription) + self.transition(to: .failed(failure)) + self.releaseSession() + } + } + return + } var arguments = arguments var initializationOptions = initializationOptions - var workspaceConfiguration: ToolingJSONValue? + var workspaceConfiguration = preparedWorkspaceConfiguration if let launchResolver { do { let resolved = try launchResolver(normalizedRoot) @@ -201,54 +264,117 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { ([executableURL.path] + arguments).joined(separator: " "), nil ) - let started = if let pluginID, !hostServices.isEmpty { - core.startPluginLanguageServer( - pluginID: pluginID, - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - initializationOptions: initializationOptions, - hostServices: hostServices, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) - } else if let pluginID { - core.startPluginLanguageServer( - pluginID: pluginID, - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - initializationOptions: initializationOptions, - workspaceConfiguration: workspaceConfiguration, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) - } else { - core.startLanguageServer( - providerID: providerID, - executableURL: executableURL, - arguments: arguments, - environment: environment, - rootURL: normalizedRoot, - workingDirectoryURL: normalizedRoot, - initializationOptions: initializationOptions, - runtimeExecutableURL: hostIntegrationLaunch?.runtimeExecutableURL, - jdtlsLaunchResources: hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources, - cacheDirectoryURL: cacheDirectoryURL, - workspaceFingerprint: workspaceFingerprint, - mavenContext: mavenContext, - javaGradle: javaGradle, - initializeTimeout: initializeTimeout, - requestTimeout: requestTimeout, - shutdownTimeout: shutdownTimeout - ) + let core = core + let pluginID = pluginID + let hostServices = hostServices + let providerID = providerID + let executableURL = executableURL + let environment = environment + let initializeTimeout = initializeTimeout + let requestTimeout = requestTimeout + let shutdownTimeout = shutdownTimeout + let hostIntegrationLaunch = hostIntegrationLaunch + let cacheDirectoryURL = cacheDirectoryURL + let resolvedArguments = arguments + let resolvedOptions = initializationOptions + let resolvedConfiguration = workspaceConfiguration + let launch: @Sendable () -> Result = { + if let pluginID, !hostServices.isEmpty { + core.startPluginLanguageServer( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: resolvedOptions, + hostServices: hostServices, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else if let pluginID { + core.startPluginLanguageServer( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: resolvedOptions, + workspaceConfiguration: resolvedConfiguration, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else { + core.startLanguageServer( + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + workingDirectoryURL: normalizedRoot, + initializationOptions: resolvedOptions, + runtimeExecutableURL: hostIntegrationLaunch?.runtimeExecutableURL, + jdtlsLaunchResources: hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources, + cacheDirectoryURL: cacheDirectoryURL, + workspaceFingerprint: workspaceFingerprint, + mavenContext: mavenContext, + javaGradle: javaGradle, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } + } + usesBackgroundStart = core.requiresBackgroundLanguageServerStart(rootURL: normalizedRoot) + if usesBackgroundStart { + let generation = UUID() + startupGeneration = generation + let resources = releaseResources + releaseResources = nil + Task.detached(priority: .userInitiated) { [weak self] in + let started = launch() + // The completion claims the result only while its generation + // is active; a closed/replaced owner destroys the late session. + if await self?.completeBackgroundStart(started, generation: generation, resources: resources) != true { + if case .success(let payload) = started { core.destroyLanguageServer(sessionID: payload.sessionID) } + await resources?() + } + } + return + } + do { try acceptStart(launch()) } + catch { + if sessionID != nil { stop() } + else { releaseSession() } + throw error } + } + + private func completeBackgroundStart( + _ result: Result, + generation: UUID, + resources: (@Sendable () async -> Void)? + ) -> Bool { + guard startupGeneration == generation else { return false } + startupGeneration = nil + releaseResources = resources + do { + try acceptStart(result) + } catch { + startingDocuments = [:] + startingDocumentBytes = 0 + if sessionID != nil { + onLog?(.error, "Language server document sync failed", error.localizedDescription, nil) + stop() + } else { releaseSession() } + } + return true + } + + private func acceptStart(_ started: Result) throws { switch started { case .success(let payload): sessionID = payload.sessionID @@ -258,6 +384,16 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } transition(to: Self.sessionState(payload.state) ?? .initializing) startPolling() + let changes = startingFileChanges + startingFileChanges = [] + if !changes.isEmpty { try notifyWorkspaceFilesChanged(changes) } + let documents = startingDocuments + startingDocuments = [:] + startingDocumentBytes = 0 + for url in documents.keys.sorted(by: { $0.path < $1.path }) { + guard let document = documents[url] else { continue } + try synchronize(fileURL: url, text: document.text, languageID: document.languageID) + } case .failure(let error): let message = "Language server failed to start: \(error.userMessage)" let failure = LanguageServerSessionFailure( @@ -272,6 +408,16 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func synchronize(fileURL: URL, text: String, languageID: String) throws { + if startupGeneration != nil { + let url = fileURL.standardizedFileURL + let bytes = startingDocumentBytes - (startingDocuments[url]?.text.utf8.count ?? 0) + text.utf8.count + guard bytes <= Self.startingDocumentByteLimit, startingDocuments[url] != nil || startingDocuments.count < 512 else { + throw LanguageServerRuntimeSessionError.documentSyncFailed(L10n.string("Remote pending documents exceeded the startup limit. Wait for the language server to start.")) + } + startingDocuments[url] = StartingDocument(text: text, languageID: languageID) + startingDocumentBytes = bytes + return + } guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } // Documents synced before initialize completes are held by the runtime and // opened once the server is ready, so there is nothing to queue here. @@ -294,6 +440,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { languageID: String, changes: [LanguageServerDocumentChange] ) throws { + if startupGeneration != nil { + return try synchronize(fileURL: fileURL, text: text, languageID: languageID) + } guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } guard let incrementalCore = core as? any IncrementalLanguageServerRuntimeCore else { return try synchronize(fileURL: fileURL, text: text, languageID: languageID) @@ -314,8 +463,15 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { package func notifyWorkspaceFilesChanged( _ changes: [LanguageServerWorkspaceFileChange] ) throws { - guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } guard !changes.isEmpty else { return } + if startupGeneration != nil { + guard changes.count <= 4096 - startingFileChanges.count else { + throw LanguageServerRuntimeSessionError.notReady + } + startingFileChanges.append(contentsOf: changes) + return + } + guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } switch core.notifyLanguageServerWorkspaceFilesChanged( sessionID: sessionID, changes: changes @@ -339,6 +495,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func closeDocument(_ fileURL: URL) { + if let removed = startingDocuments.removeValue(forKey: fileURL.standardizedFileURL) { + startingDocumentBytes -= removed.text.utf8.count + } guard let sessionID else { return } documentVersions[fileURL.standardizedFileURL] = nil // The runtime owns which documents are open, so closing one it does not @@ -874,11 +1033,19 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } package func stop() { + let wasStarting = startupGeneration != nil + preparationTask?.cancel() + preparationTask = nil + startupGeneration = nil + startingDocuments = [:] + startingDocumentBytes = 0 + startingFileChanges = [] configurationUpdateGeneration &+= 1 configurationUpdateTask = nil guard let sessionID else { failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) transition(to: .stopped) + if wasStarting || releaseResources != nil { releaseSession() } return } // The runtime sends the shutdown, force-terminates on its own deadline, @@ -895,6 +1062,9 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { watchdog.arm(after: stuckSendGrace) { [weak self] in self?.endStuckSend() } stopWatchdog = watchdog } + } else if usesBackgroundStart { + let core = core + Task.detached(priority: .userInitiated) { core.stopLanguageServer(sessionID: sessionID) } } else { core.stopLanguageServer(sessionID: sessionID) } @@ -1144,13 +1314,28 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// Hands the session back to the runtime once it has reached a terminal state. private func releaseSession() { + startingDocuments = [:] + startingDocumentBytes = 0 + startingFileChanges = [] pollTask = nil stopWatchdog?.disarm() stopWatchdog = nil functionProgress = [:] failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) + let releaseResources = releaseResources + self.releaseResources = nil if let sessionID { - core.destroyLanguageServer(sessionID: sessionID) + if usesBackgroundStart || releaseResources != nil { + let core = core + Task.detached(priority: .userInitiated) { + core.destroyLanguageServer(sessionID: sessionID) + await releaseResources?() + } + } else { + core.destroyLanguageServer(sessionID: sessionID) + } + } else if let releaseResources { + Task.detached { await releaseResources() } } sessionID = nil documentVersions = [:] diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index cdb291db3e..f22023ba38 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -1634,7 +1634,7 @@ package final class LanguageToolingSessionManager: ObservableObject, detail: descriptor.languageServerLaunch?.executableNames.joined(separator: ", ") ) - guard let created = runtime.makeLanguageServerSession() else { + guard let created = runtime.makeLanguageServerSession(rootURL: rootURL) else { let message = runtime.unavailableToolingMessage ?? descriptor.displayName let state = LanguageServerSessionState.failed(LanguageServerSessionFailure( code: "tool_not_found", diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index 41267cb7f3..1b2b77316c 100644 --- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift +++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -8,21 +8,68 @@ public final class TerminalFeatureModel: ObservableObject { @Published public private(set) var activeTerminalSessionID: UUID? @Published public private(set) var availableShells: [String] = [] + @Published public private(set) var isDiscoveringShells = false + @Published public private(set) var shellDiscoveryError: String? private let terminalFactory: () -> any TerminalTransport + private let workspaceTerminalFactory: ((URL) -> any TerminalTransport)? private let shellDiscovery: () -> [String] + private let workspaceShellDiscovery: ((URL) async throws -> [String]?)? + private var shellWorkspace: URL? + private var shellDiscoveryTask: Task? + private var shellDiscoveryGeneration = UUID() + private var pendingProcessSessions: [UUID: TerminalSession] = [:] public init( terminalFactory: @escaping () -> any TerminalTransport, - shellDiscovery: @escaping () -> [String] = { [] } + workspaceTerminalFactory: ((URL) -> any TerminalTransport)? = nil, + shellDiscovery: @escaping () -> [String] = { [] }, + workspaceShellDiscovery: ((URL) async throws -> [String]?)? = nil ) { self.terminalFactory = terminalFactory + self.workspaceTerminalFactory = workspaceTerminalFactory self.shellDiscovery = shellDiscovery + self.workspaceShellDiscovery = workspaceShellDiscovery refreshAvailableShells() } public func refreshAvailableShells() { - availableShells = shellDiscovery() + shellDiscoveryTask?.cancel() + let generation = UUID() + shellDiscoveryGeneration = generation + shellDiscoveryError = nil + guard let root = shellWorkspace, let discover = workspaceShellDiscovery else { + isDiscoveringShells = false + availableShells = shellDiscovery() + return + } + // Clear the previous host before starting I/O; stale results must never + // offer a local executable in a remote workspace's shell menu. + availableShells = [] + isDiscoveringShells = true + shellDiscoveryTask = Task { [weak self] in + do { + let shells = try await discover(root) + guard let self, !Task.isCancelled, shellDiscoveryGeneration == generation else { return } + availableShells = shells ?? shellDiscovery() + isDiscoveringShells = false + shellDiscoveryTask = nil + } catch { + guard let self, !Task.isCancelled, shellDiscoveryGeneration == generation else { return } + shellDiscoveryError = error.localizedDescription + isDiscoveringShells = false + shellDiscoveryTask = nil + } + } + } + + /// Binds discovery to the window's workspace, independently of which + /// terminal tab is selected or whether a terminal has been created yet. + public func setShellWorkspace(_ root: URL?) { + let root = root?.standardizedFileURL + guard shellWorkspace != root else { return } + shellWorkspace = root + if workspaceShellDiscovery != nil { refreshAvailableShells() } } public var activeTerminalSession: TerminalSession? { @@ -30,6 +77,11 @@ public final class TerminalFeatureModel: ObservableObject { return terminalSessions.first { $0.id == activeTerminalSessionID } } + /// A launch awaiting host acknowledgement still owns a process resource. + public var hasActiveSessions: Bool { + !pendingProcessSessions.isEmpty || terminalSessions.contains { $0.isRunning || $0.startedAt != nil && $0.endedAt == nil } + } + public func terminalTitle(for session: TerminalSession) -> String { if let processTitle = session.processTitle, !processTitle.isEmpty { return processTitle } guard let index = terminalSessions.firstIndex(where: { $0.id == session.id }) else { return "Local" } @@ -51,7 +103,8 @@ public final class TerminalFeatureModel: ObservableObject { preferredToolTabTitle: String? = nil, showsWorkbenchBackground: Bool = false ) -> TerminalSession { - let transport = terminalFactory() + setShellWorkspace(workspaceURL) + let transport = workspaceTerminalFactory?(workspaceURL) ?? terminalFactory() transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) let session = TerminalSession(transport: transport, preferredToolTabTitle: preferredToolTabTitle) session.start(in: workspaceURL, shellPath: shellPath) @@ -66,7 +119,7 @@ public final class TerminalFeatureModel: ObservableObject { showsWorkbenchBackground: Bool = false, onOutput: ((String) -> Void)? = nil ) throws -> (session: TerminalSession, processID: Int32) { - let transport = terminalFactory() + let transport = workspaceTerminalFactory?(URL(fileURLWithPath: launch.workingDirectory)) ?? terminalFactory() transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) let session = TerminalSession(transport: transport) session.onOutput = onOutput @@ -76,6 +129,26 @@ public final class TerminalFeatureModel: ObservableObject { return (session, processID) } + /// Uses workspace identity for host selection even when a debugger supplies + /// an absolute working directory on that host. + public func createProcessSessionAsync( + _ launch: TerminalProcessLaunch, + workspaceURL: URL, + showsWorkbenchBackground: Bool = false, + onOutput: ((String) -> Void)? = nil + ) async throws -> (session: TerminalSession, processID: Int32) { + let transport = workspaceTerminalFactory?(workspaceURL) ?? terminalFactory() + transport.configureInitialSurface(showsWorkbenchBackground: showsWorkbenchBackground) + let session = TerminalSession(transport: transport) + session.onOutput = onOutput + pendingProcessSessions[session.id] = session + defer { pendingProcessSessions[session.id] = nil } + let processID = try await session.startProcessAsync(launch) + terminalSessions.append(session) + activeTerminalSessionID = session.id + return (session, processID) + } + @discardableResult public func selectSession(_ session: TerminalSession) -> Bool { guard terminalSessions.contains(where: { $0.id == session.id }) else { return false } @@ -110,6 +183,9 @@ public final class TerminalFeatureModel: ObservableObject { } public func stopAllSessions() { + cancelShellDiscovery() + pendingProcessSessions.values.forEach { $0.stop() } + pendingProcessSessions.removeAll() terminalSessions.forEach { $0.stop() } terminalSessions.removeAll() activeTerminalSessionID = nil @@ -117,7 +193,10 @@ public final class TerminalFeatureModel: ObservableObject { /// Stops every session and waits until their process sessions are gone. public func stopAllSessionsAndWait() async { - let sessions = terminalSessions + let discovery = shellDiscoveryTask + cancelShellDiscovery() + let sessions = terminalSessions + pendingProcessSessions.values + pendingProcessSessions.removeAll() terminalSessions.removeAll() activeTerminalSessionID = nil // Started together so the bounded grace periods overlap. @@ -125,5 +204,15 @@ public final class TerminalFeatureModel: ObservableObject { Task { @MainActor in await session.stopAndWait() } } for stop in stops { await stop.value } + await discovery?.value } + + private func cancelShellDiscovery() { + shellDiscoveryGeneration = UUID() + shellDiscoveryTask?.cancel() + shellDiscoveryTask = nil + isDiscoveringShells = false + } + + deinit { shellDiscoveryTask?.cancel() } } diff --git a/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift b/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift index a86ec855d8..a0bf72d47b 100644 --- a/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift +++ b/macos/Sources/LitheTerminalModule/Module/TerminalModule.swift @@ -14,22 +14,30 @@ public final class TerminalModule: LitheModule { public let manifest = moduleManifest private let terminalFactory: @MainActor () -> any TerminalTransport + private let workspaceTerminalFactory: (@MainActor (URL) -> any TerminalTransport)? private let shellDiscovery: @MainActor () -> [String] + private let workspaceShellDiscovery: (@MainActor (URL) async throws -> [String]?)? private var capability: TerminalModuleCapability? public init( terminalFactory: @escaping @MainActor () -> any TerminalTransport, - shellDiscovery: @escaping @MainActor () -> [String] = { [] } + workspaceTerminalFactory: (@MainActor (URL) -> any TerminalTransport)? = nil, + shellDiscovery: @escaping @MainActor () -> [String] = { [] }, + workspaceShellDiscovery: (@MainActor (URL) async throws -> [String]?)? = nil ) { self.terminalFactory = terminalFactory + self.workspaceTerminalFactory = workspaceTerminalFactory self.shellDiscovery = shellDiscovery + self.workspaceShellDiscovery = workspaceShellDiscovery } public func activate(context: ModuleContext) async throws { guard capability == nil else { return } let feature = TerminalFeatureModel( terminalFactory: terminalFactory, - shellDiscovery: shellDiscovery + workspaceTerminalFactory: workspaceTerminalFactory, + shellDiscovery: shellDiscovery, + workspaceShellDiscovery: workspaceShellDiscovery ) let resource = TerminalSessionResource(feature: feature) context.resources.register(resource) @@ -37,7 +45,7 @@ public final class TerminalModule: LitheModule { } public func prepareForSleep() async throws { - guard capability?.feature.terminalSessions.allSatisfy({ !$0.isRunning }) != false else { + guard capability?.feature.hasActiveSessions != true else { throw TerminalModuleSleepError.runningSession } } @@ -73,6 +81,6 @@ private final class TerminalSessionResource: ModuleResource { let feature: TerminalFeatureModel init(feature: TerminalFeatureModel) { self.feature = feature } var moduleResourceKind: String { "terminal-sessions" } - var isModuleResourceActive: Bool { feature.terminalSessions.contains(where: \.isRunning) } + var isModuleResourceActive: Bool { feature.hasActiveSessions } func stopModuleResource() async { await feature.stopAllSessionsAndWait() } } diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift index 3e4c7eb30b..1bcea35a26 100644 --- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift +++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -73,6 +73,9 @@ public protocol TerminalTransport: AnyObject { /// Whether the host terminal renders a light background, advertised through `COLORFGBG` /// so TUIs pick a palette without querying; hosts that cannot tell keep it unset. var reportsLightBackground: Bool? { get } + /// Network PTYs become ready after a remote acknowledgement. Local PTYs + /// keep their existing synchronous launch behavior. + var requiresAsyncStart: Bool { get } /// Applies the host surface state to the native view before the shell /// starts, so the exported `COLORFGBG` matches the OSC 11 answer the @@ -82,7 +85,10 @@ public protocol TerminalTransport: AnyObject { func defaultShellPath() -> String func defaultEnvironment() -> [String: String] func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws + func startShell(workingDirectory: String, shellPath: String, environment: [String: String]) async throws func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32 + /// Waits for the host to acknowledge a managed process without blocking UI work. + func startProcessAsync(_ launch: TerminalProcessLaunch, environment: [String: String]) async throws -> Int32 func send(_ input: Data) throws func interrupt() throws func focus() @@ -99,6 +105,13 @@ public protocol TerminalTransport: AnyObject { } public extension TerminalTransport { + var requiresAsyncStart: Bool { false } + func startShell(workingDirectory: String, shellPath: String, environment: [String: String]) async throws { + try start(workingDirectory: workingDirectory, shellPath: shellPath, environment: environment) + } + func startProcessAsync(_ launch: TerminalProcessLaunch, environment: [String: String]) async throws -> Int32 { + try startProcess(launch, environment: environment) + } var termProgramVersion: String? { nil } func stopAndWait() async { stop() } func runningPrograms() -> [TerminalRunningProgram] { [] } diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift index ec43d02437..c0383824e1 100644 --- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift +++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -27,6 +27,8 @@ public final class TerminalSession: ObservableObject, Identifiable { private var workspaceURL: URL? private var selectedShellPath: String? private var outputDecoder = StreamingUTF8Decoder() + private var launchTask: Task? + private var launchGeneration = UUID() public init(transport: any TerminalTransport, preferredToolTabTitle: String? = nil) { self.transport = transport @@ -76,6 +78,23 @@ public final class TerminalSession: ObservableObject, Identifiable { selectedShellPath = shell shellName = URL(fileURLWithPath: shell).lastPathComponent let environment = terminalEnvironment() + if transport.requiresAsyncStart { + let generation = UUID() + launchGeneration = generation + launchTask = Task { [weak self] in + guard let self, !Task.isCancelled, launchGeneration == generation else { return } + do { + try await transport.startShell(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment) + guard !Task.isCancelled, launchGeneration == generation else { return } + isRunning = transport.isRunning; isReady = isRunning + } catch { + guard !Task.isCancelled, launchGeneration == generation else { return } + launchError = error.localizedDescription + isRunning = false; isReady = false; startedAt = nil; endedAt = Date() + } + } + return + } do { try transport.start(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment) isRunning = transport.isRunning; isReady = isRunning @@ -87,6 +106,44 @@ public final class TerminalSession: ObservableObject, Identifiable { @discardableResult public func startProcess(_ launch: TerminalProcessLaunch) throws -> Int32 { + prepareProcess(launch) + do { + let processID = try transport.startProcess( + launch, + environment: terminalEnvironment(applying: launch.environmentChanges) + ) + isRunning = transport.isRunning + isReady = isRunning + return processID + } catch { + failProcessStart(error) + throw error + } + } + + @discardableResult + public func startProcessAsync(_ launch: TerminalProcessLaunch) async throws -> Int32 { + prepareProcess(launch) + let generation = launchGeneration + do { + let processID = try await transport.startProcessAsync( + launch, + environment: terminalEnvironment(applying: launch.environmentChanges) + ) + guard !Task.isCancelled, launchGeneration == generation else { throw CancellationError() } + isRunning = transport.isRunning + isReady = isRunning + return processID + } catch { + if launchGeneration == generation { + transport.stop() + failProcessStart(error) + } + throw error + } + } + + private func prepareProcess(_ launch: TerminalProcessLaunch) { stop() let workingDirectory = URL( fileURLWithPath: launch.workingDirectory, @@ -103,23 +160,14 @@ public final class TerminalSession: ObservableObject, Identifiable { startedAt = Date() endedAt = nil shellName = URL(fileURLWithPath: launch.executablePath).lastPathComponent + } - do { - let processID = try transport.startProcess( - launch, - environment: terminalEnvironment(applying: launch.environmentChanges) - ) - isRunning = transport.isRunning - isReady = isRunning - return processID - } catch { - launchError = error.localizedDescription - isRunning = false - isReady = false - startedAt = nil - endedAt = Date() - throw error - } + private func failProcessStart(_ error: Error) { + launchError = error.localizedDescription + isRunning = false + isReady = false + startedAt = nil + endedAt = Date() } public func restart() { @@ -139,13 +187,21 @@ public final class TerminalSession: ObservableObject, Identifiable { public func clear() { transport.clear() } public func focus() { transport.focus() } public func stop() { + launchGeneration = UUID() + launchTask?.cancel() + launchTask = nil transport.stop() finishStop() } /// Stops the whole terminal session and waits for its processes to exit. public func stopAndWait() async { + launchGeneration = UUID() + let launch = launchTask + launchTask = nil + launch?.cancel() await transport.stopAndWait() + await launch?.value finishStop() } diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift index f0b4be5c57..14690cb440 100644 --- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift +++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -86,6 +86,38 @@ struct TerminalModuleTests { #expect(feature.terminalSessions.isEmpty) } + @Test + func workspaceShellDiscoveryRejectsStaleResultsAndDoesNotFallBackAfterFailure() async { + let first = URL(fileURLWithPath: "/fixture/first") + let second = URL(fileURLWithPath: "/fixture/second") + let gate = AsyncStream.makeStream() + var requested: [URL] = [] + let feature = TerminalFeatureModel(terminalFactory: { TestTransport() }, shellDiscovery: { ["/local/shell"] }, + workspaceShellDiscovery: { root in + requested.append(root) + if root == first { + var iterator = gate.stream.makeAsyncIterator() + _ = await iterator.next() + // Deliberately return a value after cancellation: the + // owning feature must reject the old host's completion. + return ["/first/shell"] + } + throw NSError(domain: "ShellFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: "fixture disconnected"]) + }) + defer { feature.stopAllSessions(); gate.continuation.finish() } + feature.setShellWorkspace(first) + #expect(feature.availableShells.isEmpty) + #expect(await shellDiscoveryEventually { requested == [first] }) + feature.setShellWorkspace(second) + #expect(await shellDiscoveryEventually { !feature.isDiscoveringShells }) + #expect(requested == [first, second]) + #expect(feature.availableShells.isEmpty) + #expect(feature.shellDiscoveryError == "fixture disconnected") + feature.setShellWorkspace(nil) + #expect(feature.availableShells == ["/local/shell"]) + #expect(feature.shellDiscoveryError == nil) + } + @Test func managedProcessLaunchPreservesArgumentsEnvironmentAndProcessID() throws { let transport = TestTransport() @@ -337,6 +369,16 @@ struct TerminalModuleTests { } } +@MainActor +private func shellDiscoveryEventually(_ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + @MainActor private final class TestTransport: TerminalTransport { let nativeView: AnyObject = NSObject() diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 720c256beb..df70612315 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -9,6 +9,165 @@ import Testing /// process a request reuses, and when the host stops or restarts it. @MainActor struct MacBuildProviderSessionsTests { + @Test + func preparedPluginKeepsConfigurationAndQueuedWorkspaceChanges() async throws { + let core = ScriptedProviderCore() + let preparing = TestGate() + let resume = TestGate() + let released = TestGate() + let root = URL(fileURLWithPath: "/workspace") + let configuration: ToolingJSONValue = .object(["fixture": .integer(7)]) + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/unused"), arguments: [], environment: [:], + prepareProcess: { _ in + preparing.open() + guard await resume.waitUntilOpen() else { throw CancellationError() } + return PreparedLanguageServerProcess(executableURL: URL(fileURLWithPath: "/host/bin/server"), + environment: [:], launch: .init(arguments: ["--remote"], initializationOptions: nil, + workspaceConfiguration: configuration), core: core, release: { released.open() }) + }, core: core, pluginID: "dev.example.fixture" + ) + defer { resume.open(); session.stop() } + try session.start(rootURL: root, workspaceFingerprint: nil) + try #require(await preparing.waitUntilOpen()) + let changes = [LanguageServerWorkspaceFileChange(fileURL: root.appendingPathComponent("new.swift"), kind: .created)] + try session.notifyWorkspaceFilesChanged(changes) + try session.synchronize(fileURL: root.appendingPathComponent("open.swift"), text: "latest", languageID: "swift") + resume.open() + try #require(await core.synchronized.waitUntilOpen()) + #expect(core.workspaceConfigurations == [configuration]) + #expect(core.workspaceChanges == changes) + #expect(core.startCalls.last?.arguments == ["--remote"]) + session.stop() + #expect(await released.waitUntilOpen()) + } + + @Test + func stoppedPreparationReleasesLatePackageWithoutLaunching() async throws { + let core = ScriptedProviderCore() + let preparing = TestGate() + let resume = TestGate() + let released = TestGate() + // Model a host allocation whose reply arrives after cancellation. + let lateReply = Task { @MainActor in await resume.waitUntilOpen() } + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/unused"), arguments: [], environment: [:], + prepareProcess: { _ in + preparing.open() + guard await lateReply.value else { throw CancellationError() } + return PreparedLanguageServerProcess(executableURL: URL(fileURLWithPath: "/host/bin/server"), + environment: [:], launch: .init(arguments: [], initializationOptions: nil), + core: core, release: { released.open() }) + }, core: core + ) + defer { resume.open(); lateReply.cancel(); session.stop() } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + try #require(await preparing.waitUntilOpen()) + session.stop() + resume.open() + #expect(await released.waitUntilOpen()) + #expect(core.startCalls.isEmpty) + #expect(!session.isRunning) + } + + @Test + func failedPreparedProcessReleasesItsPackage() async throws { + let core = ScriptedProviderCore(failStart: true) + let released = TestGate() + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/unused"), arguments: [], environment: [:], + prepareProcess: { _ in + PreparedLanguageServerProcess(executableURL: URL(fileURLWithPath: "/host/bin/server"), + environment: [:], launch: .init(arguments: [], initializationOptions: nil), + core: core, release: { released.open() }) + }, core: core + ) + defer { session.stop() } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + #expect(await released.waitUntilOpen()) + #expect(!session.isRunning) + } + + @Test + func remoteStartupReturnsBeforeTheHostRepliesAndFlushesLatestDocuments() async throws { + let core = ScriptedProviderCore(backgroundStart: true) + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/host/bin/server"), + arguments: [], environment: [:], core: core + ) + defer { core.releaseStartup.open(); session.stop() } + let root = URL(fileURLWithPath: "/workspace") + let file = root.appendingPathComponent("one.swift") + let closedFile = root.appendingPathComponent("closed.swift") + try session.start(rootURL: root, workspaceFingerprint: nil) + try #require(await core.started.waitUntilOpen()) + #expect(session.isRunning) + try session.synchronize(fileURL: file, text: "old", languageID: "swift") + try session.synchronize(fileURL: file, text: "latest", languageID: "swift", changes: []) + try session.synchronize(fileURL: closedFile, text: "closed", languageID: "swift") + session.closeDocument(closedFile) + #expect(core.syncedTexts.isEmpty) + + core.releaseStartup.open() + try #require(await core.synchronized.waitUntilOpen()) + #expect(core.syncedTexts == ["latest"]) + session.stop() + #expect(await core.destroyed.waitUntilOpen()) + } + + @Test + func stoppingRemoteStartupDestroysItsLateSessionWithoutPublishingReady() async throws { + let core = ScriptedProviderCore(backgroundStart: true) + let session = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/host/bin/server"), + arguments: [], environment: [:], core: core + ) + defer { core.releaseStartup.open(); session.stop() } + var states: [LanguageServerSessionState] = [] + var releases = 0 + let resourcesReleased = TestGate() + session.onStateChange = { states.append($0) } + session.onReleased = { releases += 1 } + session.releaseResources = { + #expect(core.destroyed.isOpen, "Package bytes must outlive the late process") + resourcesReleased.open() + } + try session.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + try #require(await core.started.waitUntilOpen()) + session.stop() + #expect(!session.isRunning) + #expect(releases == 1) + #expect(!resourcesReleased.isOpen) + core.releaseStartup.open() + #expect(await core.destroyed.waitUntilOpen()) + #expect(await resourcesReleased.waitUntilOpen()) + #expect(states == [.startingProcess, .stopped]) + #expect(core.stopCalls.isEmpty) + } + + @Test + func releasingRemoteStartupOwnerStillDestroysTheLateSession() async throws { + let core = ScriptedProviderCore(backgroundStart: true) + var session: LanguageServerRuntimeSession? = LanguageServerRuntimeSession( + providerID: "fixture", executableURL: URL(fileURLWithPath: "/host/bin/server"), + arguments: [], environment: [:], core: core + ) + defer { core.releaseStartup.open(); session?.stop() } + let resourcesReleased = TestGate() + session?.releaseResources = { + #expect(core.destroyed.isOpen) + resourcesReleased.open() + } + weak var owner = session + try session?.start(rootURL: URL(fileURLWithPath: "/workspace"), workspaceFingerprint: nil) + try #require(await core.started.waitUntilOpen()) + session = nil + #expect(owner == nil) + core.releaseStartup.open() + #expect(await core.destroyed.waitUntilOpen()) + #expect(await resourcesReleased.waitUntilOpen()) + } + /// A second request reuses the process; stopping the plugin's providers, /// which the plugin manager does before replacing its package, ends it /// and the next request starts a new one. @@ -436,12 +595,16 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked let functionStarted = TestGate() let functionCancelled = TestGate() let destroyed = TestGate() + let releaseStartup = TestGate() + let synchronized = TestGate() /// Opened when a `workspace.stuck` send is blocked; it stays blocked, as /// a write to a backend that stopped reading would, until `releaseStuckSend`. let stuckSendStarted = TestGate() private let stuckSendReleased = TestGate() private let initialState: String private let readyAfterStop: Bool + private let backgroundStart: Bool + private let failStart: Bool private let lock = NSLock() private var nextSession = 0 private var events: [String: [LanguageServerRuntimeEvent]] = [:] @@ -450,12 +613,26 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked private var _buildRequests: [String] = [] private var _buildParams: [ToolingJSONValue] = [] private var _functionRequests: [FunctionCall] = [] + private var _syncedTexts: [String] = [] + private var _workspaceConfigurations: [ToolingJSONValue?] = [] + private var _workspaceChanges: [LanguageServerWorkspaceFileChange] = [] - init(initialState: String = "ready", readyAfterStop: Bool = false) { + init(initialState: String = "ready", readyAfterStop: Bool = false, backgroundStart: Bool = false, failStart: Bool = false) { self.initialState = initialState self.readyAfterStop = readyAfterStop + self.backgroundStart = backgroundStart + self.failStart = failStart } + func requiresBackgroundLanguageServerStart(rootURL _: URL) -> Bool { backgroundStart } + var syncedTexts: [String] { lock.withLock { _syncedTexts } } + var workspaceConfigurations: [ToolingJSONValue?] { lock.withLock { _workspaceConfigurations } } + var workspaceChanges: [LanguageServerWorkspaceFileChange] { lock.withLock { _workspaceChanges } } + + func notifyLanguageServerWorkspaceFilesChanged(sessionID _: String, changes: [LanguageServerWorkspaceFileChange]) -> Result { + lock.withLock { _workspaceChanges.append(contentsOf: changes) } + return .success(()) + } var startCalls: [StartCall] { lock.withLock { _startCalls } } var stopCalls: [String] { lock.withLock { _stopCalls } } var buildRequests: [String] { lock.withLock { _buildRequests } } @@ -486,6 +663,13 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked workspaceFingerprint _: String?, mavenContext _: MavenLaunchContext?, initializeTimeout _: TimeInterval, requestTimeout _: TimeInterval, shutdownTimeout _: TimeInterval ) -> Result { + if failStart { return .failure(.init(code: "processFailed", message: "fixture startup failed")) } + if backgroundStart { + started.open() + guard releaseStartup.waitSynchronously() else { + return .failure(.init(code: "timed_out", message: "The test did not release startup.")) + } + } let result = lock.withLock { nextSession += 1 _startCalls.append(StartCall(arguments: arguments, pluginID: nil)) @@ -500,6 +684,18 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked /// Core queues the negotiated functions before the session turns ready. /// With `initialState: "initializing"` the session stays initializing /// until `becomeReady()`, or until a stop queues the late ready reply. + func startPluginLanguageServer( + pluginID: String, providerID: String, executableURL: URL, arguments: [String], + environment: [String: String], rootURL: URL, initializationOptions: ToolingJSONValue?, + workspaceConfiguration: ToolingJSONValue?, initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, shutdownTimeout: TimeInterval + ) -> Result { + lock.withLock { _workspaceConfigurations.append(workspaceConfiguration) } + return startPluginLanguageServer(pluginID: pluginID, providerID: providerID, executableURL: executableURL, + arguments: arguments, environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout) + } + func startPluginLanguageServer( pluginID: String, providerID _: String, executableURL _: URL, arguments: [String], environment: [String: String], rootURL _: URL, initializationOptions _: ToolingJSONValue?, @@ -631,9 +827,11 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked } func syncLanguageServerDocument( - sessionID _: String, fileURL _: URL, languageID _: String, text _: String + sessionID _: String, fileURL _: URL, languageID _: String, text: String ) -> Result { - .success(LanguageServerDocumentSync(documentVersion: 1, changed: false)) + lock.withLock { _syncedTexts.append(text) } + synchronized.open() + return .success(LanguageServerDocumentSync(documentVersion: 1, changed: false)) } func closeLanguageServerDocument(sessionID _: String, fileURL _: URL) {} diff --git a/macos/Tests/LitheTests/RemoteBuildExecutionTests.swift b/macos/Tests/LitheTests/RemoteBuildExecutionTests.swift new file mode 100644 index 0000000000..906d1b36ff --- /dev/null +++ b/macos/Tests/LitheTests/RemoteBuildExecutionTests.swift @@ -0,0 +1,156 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Remote build execution") +struct RemoteBuildExecutionTests { + private let root = URL(fileURLWithPath: "/fixture/build-placeholder") + + private func registry() -> RemoteWorkspaceRegistry { + let registry = RemoteWorkspaceRegistry() + registry.register(.init(root: root, remoteRoot: "/host/project", sessionID: "original", connectionID: UUID())) + return registry + } + + @Test + func mapsOnlyTypedPathsAndCapturesStdoutSeparately() async throws { + let host = BuildProcessFixture() + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry(), host: host) + let streamed = BuildOutputFixture() + let opaque = "--message=\(root.path)/keep-this-literal" + let result = try await runner.run(.init(executableURL: root.appendingPathComponent("tool"), + arguments: [opaque], workingDirectory: root.appendingPathComponent("module"), + environment: ["VALUE": root.path], capturesStandardOutput: true, timeout: .seconds(12))) { + streamed.append($0) + } + #expect(host.calls.first?.0 == RemoteProcessRequest(argv: ["/host/project/tool", opaque], + cwd: "/host/project/module", env: ["VALUE": root.path])) + #expect(host.calls.first?.1 == "original") + #expect(host.calls.first?.2 == .seconds(12)) + #expect(result.standardOutput == "model") + #expect(streamed.value == "warning") + #expect(result.termination == .exited(0)) + } + + @Test + func interleavedStreamsKeepTheirOwnPartialUTF8Codepoints() async throws { + let host = BuildProcessFixture() + host.events = [ + .output(Data([0xe4, 0xb8]), standardError: false), + .output(Data([0xe6, 0x96]), standardError: true), + .output(Data([0xad]), standardError: false), + .output(Data([0x87]), standardError: true) + ] + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry(), host: host) + let streamed = BuildOutputFixture() + _ = try await runner.run(.init(executableURL: URL(fileURLWithPath: "/host/bin/build"), + arguments: [], workingDirectory: root, environment: [:]), onOutput: { streamed.append($0) }) + #expect(streamed.value == "\u{4e2d}\u{6587}") + } + + @Test + func timeoutAndTransportFailureNeverRunLocally() async throws { + let host = BuildProcessFixture() + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry(), host: host) + let request = BuildCommandRequest(executableURL: URL(fileURLWithPath: "/host/bin/build"), + arguments: [], workingDirectory: root, environment: [:]) + host.failure = RemoteProbeFailure(code: "timed_out", message: "fixture") + #expect(try await runner.run(request, onOutput: { _ in }).termination == .timedOut) + host.failure = RemoteProbeFailure(code: "connection_closed", message: "fixture") + await #expect(throws: RemoteProbeFailure.self) { try await runner.run(request, onOutput: { _ in }) } + } + + @Test + func rejectsCrossHostExecutableAndPreservesSignalFailure() async throws { + let registry = registry() + registry.register(.init(root: URL(fileURLWithPath: "/fixture/second"), remoteRoot: "/second", + sessionID: "second", connectionID: UUID())) + let host = BuildProcessFixture() + let runner = MacRemoteBuildCommandRunner(local: NoLocalBuildRunner(), registry: registry, host: host) + let request = BuildCommandRequest(executableURL: URL(fileURLWithPath: "/fixture/second/tool"), + arguments: [], workingDirectory: root, environment: [:]) + await #expect(throws: CocoaError.self) { try await runner.run(request, onOutput: { _ in }) } + #expect(host.calls.isEmpty) + host.result = .init(exitCode: nil, signal: 15) + let signalled = try await runner.run(.init(executableURL: URL(fileURLWithPath: "/host/bin/build"), + arguments: [], workingDirectory: root, environment: [:]), onOutput: { _ in }) + #expect(signalled.termination == .exited(143)) + } + + @Test + func statePreparationRunsOffMainAndCleanupRetainsOriginalSession() async throws { + let registry = registry() + let host = BuildStateFixture() + let locator = MacRemoteBuildStateLocator(local: NoLocalBuildState(), registry: registry, host: host) + let resources = try await locator.prepareRun(pluginID: "fixture", buildSystemID: "build", + packageURL: URL(fileURLWithPath: "/local/plugin"), workspaceURL: root, allocatesOutputFile: true) + #expect(resources.workspaceStateDirectory.path == "/host/state") + #expect(resources.outputFile?.deletingLastPathComponent() == resources.workspaceStateDirectory) + #expect(resources.pluginDirectory == nil) + registry.register(.init(root: root, remoteRoot: "/host/project", sessionID: "replacement", connectionID: UUID())) + await resources.release() + #expect(host.preparedSession == "original") + #expect(host.removedSession == "original") + #expect(!host.usedMainThread) + } +} + +private struct NoLocalBuildRunner: BuildCommandRunning { + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws -> BuildCommandResult { + Issue.record("A remote build must never fall back to a local process") + throw CocoaError(.featureUnsupported) + } +} + +private struct NoLocalBuildState: BuildSystemStateLocating { + func workspaceStateDirectory(pluginID: String, buildSystemID: String, workspaceURL: URL) throws -> URL { + Issue.record("Remote state must not be allocated locally") + throw CocoaError(.featureUnsupported) + } + func allocateOutputFile(in workspaceStateDirectory: URL) throws -> URL { throw CocoaError(.featureUnsupported) } + func removeOutputFile(_ url: URL) { Issue.record("Remote output must not be removed locally") } +} + +private final class BuildProcessFixture: RemoteProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [(RemoteProcessRequest, String, Duration?)] = [] + var calls: [(RemoteProcessRequest, String, Duration?)] { lock.withLock { recorded } } + // Each test mutates the scripted outcome only between awaited invocations. + var failure: RemoteProbeFailure? + var result = RemoteProcessResult(exitCode: 0, signal: nil) + var events: [RemoteProcessEvent] = [.output(Data("model".utf8), standardError: false), .output(Data("warning".utf8), standardError: true)] + func runProcess(_ request: RemoteProcessRequest, session: String, timeout: Duration?, + onEvent: @escaping @Sendable (RemoteProcessEvent) -> Void) async throws -> RemoteProcessResult { + lock.withLock { recorded.append((request, session, timeout)) } + if let failure { throw failure } + events.forEach(onEvent) + return result + } +} + +private final class BuildOutputFixture: @unchecked Sendable { + private let lock = NSLock() + private var text = "" + func append(_ value: String) { lock.withLock { text += value } } + var value: String { lock.withLock { text } } +} + +private final class BuildStateFixture: RemoteBuildStateAccess, @unchecked Sendable { + private let lock = NSLock() + private var prepared: String? + private var removed: String? + private var mainThread = false + var preparedSession: String? { lock.withLock { prepared } } + var removedSession: String? { lock.withLock { removed } } + var usedMainThread: Bool { lock.withLock { mainThread } } + func prepareBuildState(pluginID: String, buildSystemID: String, workspacePath: String, session: String) throws -> String { + lock.withLock { prepared = session; mainThread = mainThread || Thread.isMainThread } + #expect(workspacePath == "/host/project") + return "/host/state" + } + func removeBuildOutputFile(_ path: String, session: String) throws { + lock.withLock { removed = session; mainThread = mainThread || Thread.isMainThread } + #expect(path.hasPrefix("/host/state/")) + } +} diff --git a/macos/Tests/LitheTests/RemoteConnectionsTests.swift b/macos/Tests/LitheTests/RemoteConnectionsTests.swift index abdaef450a..1c8d50146a 100644 --- a/macos/Tests/LitheTests/RemoteConnectionsTests.swift +++ b/macos/Tests/LitheTests/RemoteConnectionsTests.swift @@ -231,6 +231,7 @@ private final class FakeTransport: RemoteWorkspaceTransport, @unchecked Sendable var directories: [String: [RemoteDirectoryEntry]] = [:] private(set) var expectedVersions: [RemoteFileVersion?] = [] private var clock: Int64 = 1 + private(set) var mutations: [RemoteFileMutation] = [] func put(_ text: String, at path: String) { lock.withLock { @@ -269,6 +270,23 @@ private final class FakeTransport: RemoteWorkspaceTransport, @unchecked Sendable } } +extension FakeTransport { + func mutate(_ mutation: RemoteFileMutation, session: String) throws { + try lock.withLock { + mutations.append(mutation) + switch mutation { + case .createDirectory(let path, _): directories[path] = [] + case .copy(let from, let to), .move(let from, let to): + guard files[to] == nil else { throw CocoaError(.fileWriteFileExists) } + guard let source = files[from] else { throw CocoaError(.fileNoSuchFile) } + files[to] = source + if case .move = mutation { files.removeValue(forKey: from) } + case .remove(let path), .trash(let path): files.removeValue(forKey: path) + } + } + } +} + /// Local operations that must not be reached for remote roots. private struct NoLocalWorkspace: WorkspaceOperations { func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { nil } @@ -319,6 +337,29 @@ struct RemoteWorkspaceRoutingTests { #expect(registry.resolve(URL(fileURLWithPath: "/tmp/lithe-tests/RemoteWorkspaces/c/h/application")) == nil) } + @Test + func remoteIndexLifecycleReachesTheHostAwareCoreAdapter() { + final class IndexOperations: WorkspaceOperations, @unchecked Sendable { + var calls: [String] = [] + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { nil } + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { calls.append("warm:" + rootURL.path) } + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) { + calls.append("update:" + changedPaths.joined(separator: ",")) + } + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { calls.append("invalidate:" + rootURL.path) } + } + let (_, _, transport, registry) = routing() + let core = IndexOperations() + let tree = RemoteRoutingWorkspaceOperations(local: core, registry: registry, transport: transport) + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) + tree.warmSearchIndex(at: root, visibilityRules: rules) + tree.updateSearchIndex(at: root, changedPaths: ["src/main.rs"], visibilityRules: rules) + tree.invalidateSearchIndex(at: root, visibilityRules: rules) + #expect(core.calls == ["warm:" + root.path, "update:src/main.rs", "invalidate:" + root.path]) + } + // Saving names the version that was read; a file changed on the host in // between is reported as a conflict with its current content. @Test @@ -381,21 +422,44 @@ struct RemoteWorkspaceRoutingTests { } @Test - func creatingRemoteFilesIsUnsupported() { + func creatingRemoteFilesAndSavingNewDocumentsNeverOverwritesExistingContent() throws { let (files, tree, transport, _) = routing() let url = root.appendingPathComponent("new.txt") + try files.createFile(at: url) + #expect(transport.text(at: "/srv/app/new.txt") == "") #expect(throws: CocoaError.self) { try files.createFile(at: url) } - #expect(throws: CocoaError.self) { try files.writeText("x", to: url) } - #expect(!tree.writeFile("x", at: root, relativePath: "new.txt")) - #expect(transport.expectedVersions.isEmpty) - #expect(transport.text(at: "/srv/app/new.txt") == nil) + try files.writeText("x", to: url) + #expect(transport.text(at: "/srv/app/new.txt") == "x") + #expect(tree.writeFile("tree", at: root, relativePath: "tree.txt")) + let saved = try files.writeDocumentText("new", to: root.appendingPathComponent("doc.txt"), expectedContent: nil) + if case .saved = saved {} else { Issue.record("New document was not created") } + let conflict = try files.writeDocumentText("overwrite", to: url, expectedContent: nil) + if case .conflict = conflict {} else { Issue.record("Existing file was overwritten") } + #expect(transport.text(at: "/srv/app/new.txt") == "x") } @Test - func unsupportedChangesToRemoteFilesFailInsteadOfTouchingTheDisk() { - let (files, _, _, _) = routing() - #expect(throws: CocoaError.self) { try files.removeItem(at: root.appendingPathComponent("a.txt")) } - #expect(throws: CocoaError.self) { try files.moveItem(at: root.appendingPathComponent("a"), to: root.appendingPathComponent("b")) } + func treeMutationsUseHostPathsAndRejectMixedHostsAndRootRemoval() throws { + let (files, _, transport, registry) = routing() + let a = root.appendingPathComponent("a.txt") + let b = root.appendingPathComponent("b.txt") + let c = root.appendingPathComponent("c.txt") + transport.put("content", at: "/srv/app/a.txt") + try files.createDirectory(at: root.appendingPathComponent("src"), withIntermediateDirectories: false) + try files.copyItem(at: a, to: b) + #expect(throws: CocoaError.self) { try files.copyItem(at: a, to: b) } + try files.moveItem(at: b, to: c) + try files.trashItem(at: c) + try files.removeItem(at: a) + #expect(transport.text(at: "/srv/app/a.txt") == nil) + #expect(transport.text(at: "/srv/app/c.txt") == nil) + #expect(transport.mutations.last == .remove(path: "/srv/app/a.txt")) + #expect(transport.mutations.contains(.trash(path: "/srv/app/c.txt"))) + #expect(throws: CocoaError.self) { try files.removeItem(at: root) } + #expect(throws: CocoaError.self) { try files.moveItem(at: a, to: URL(fileURLWithPath: "/tmp/local-target")) } + let other = URL(fileURLWithPath: "/tmp/lithe-tests/other") + registry.register(RemoteWorkspaceBinding(root: other, remoteRoot: "/other", sessionID: "other-host", connectionID: UUID())) + #expect(throws: CocoaError.self) { try files.copyItem(at: a, to: other.appendingPathComponent("a")) } } @Test diff --git a/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift b/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift new file mode 100644 index 0000000000..c1768bc65e --- /dev/null +++ b/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift @@ -0,0 +1,113 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Remote directory observation") @MainActor +struct RemoteDirectoryObservationTests { + @Test + func hostEventsMapToWorkspaceURLsAndCancelledGenerationsCannotRefresh() async throws { + let root = URL(fileURLWithPath: "/fixture/placeholder") + let registry = RemoteWorkspaceRegistry() + let binding = RemoteWorkspaceBinding(root: root, remoteRoot: "/host/project", sessionID: "one", connectionID: UUID()) + registry.register(binding) + let host = WatchFixture() + let batches = WatchBatches() + let source = RemoteDirectoryObservation( + configuration: DirectoryWatchConfiguration(workspaceRoot: root, gitContext: nil), + visibilityRules: FileVisibilityRules(hiddenDirectoryNames: [".git"], hiddenFilePatterns: []), + binding: binding, registry: registry, transport: host, onChange: batches.append + ) + defer { source.stop(); host.finish() } + source.start() + #expect(await watchEventually { host.active == 1 }) + #expect(host.requests == ["one:/host/project"]) + host.emit(.started) + #expect(batches.values.last?.requiresFullRescan == true) + host.emit(.changed(paths: ["/host/project/src/A.java", "/host/project-neighbor/a"], overflow: false)) + #expect(batches.values.last?.workspacePaths == [root.appendingPathComponent("src/A.java").path]) + host.emit(.changed(paths: [], overflow: true)) + #expect(batches.values.last?.requiresFullRescan == true) + host.emit(.invalidated(rootRemoved: true)) + #expect(batches.values.last?.watchRootsChanged == true) + let count = batches.values.count + source.stop() + // The fake deliberately retains its old callback after cancellation. + host.emit(.changed(paths: ["/host/project/stale"], overflow: false)) + #expect(batches.values.count == count) + #expect(await watchEventually { host.active == 0 }) + } + + @Test + func documentObservationsReportExternalChangesAndReleaseHostWatches() async throws { + let root = URL(fileURLWithPath: "/fixture/placeholder") + let registry = RemoteWorkspaceRegistry() + registry.register(RemoteWorkspaceBinding(root: root, remoteRoot: "/host/project", sessionID: "one", connectionID: UUID())) + let host = WatchFixture() + let files = RemoteRoutingFileOperations(local: MacWorkspaceFileOperations(), registry: registry, transport: host) + let changes = WatchDocuments() + let a = root.appendingPathComponent("src/A.java") + let b = root.appendingPathComponent("B.java") + let observation = files.observeDocuments(at: [a, b], onChange: changes.append) + defer { observation.cancel(); host.finish() } + #expect(await watchEventually { host.active == 1 }) + host.emit(.changed(paths: ["/host/project/src"], overflow: false)) + #expect(changes.values.last == [a]) + host.emit(.changed(paths: [], overflow: true)) + #expect(changes.values.last == [a, b]) + observation.cancel() + #expect(await watchEventually { host.active == 0 }) + } +} + +@MainActor private func watchEventually(_ condition: () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + +private final class WatchFixture: RemoteWorkspaceTransport, @unchecked Sendable { + private let lock = NSLock() + private var runs = 0 + private var recorded: [String] = [] + private var callback: (@Sendable (RemoteWatchEvent) -> Void)? + private var continuation: AsyncStream.Continuation? + var active: Int { lock.withLock { runs } } + var requests: [String] { lock.withLock { recorded } } + + func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + let (stream, continuation) = AsyncStream.makeStream() + lock.withLock { + runs += 1 + recorded.append(session + ":" + root) + callback = onEvent + self.continuation = continuation + } + defer { lock.withLock { runs -= 1; self.continuation = nil } } + var iterator = stream.makeAsyncIterator() + _ = await iterator.next() + } + + func emit(_ event: RemoteWatchEvent) { lock.withLock { callback }?(event) } + func finish() { lock.withLock { continuation }?.finish() } + func listDirectory(_ path: String, session id: String) throws -> [RemoteDirectoryEntry] { [] } + func readFile(_ path: String, session id: String) throws -> (data: Data, version: RemoteFileVersion) { throw CocoaError(.fileNoSuchFile) } + func writeFile(_ data: Data, to path: String, expected: RemoteFileVersion?, session id: String) throws -> RemoteWriteOutcome { .conflict } +} + +private final class WatchBatches: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [DirectoryChangeBatch] = [] + var values: [DirectoryChangeBatch] { lock.withLock { recorded } } + func append(_ batch: DirectoryChangeBatch) { lock.withLock { recorded.append(batch) } } +} + +private final class WatchDocuments: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [[URL]] = [] + var values: [[URL]] { lock.withLock { recorded } } + func append(_ urls: [URL]) { lock.withLock { recorded.append(urls) } } +} diff --git a/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift b/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift new file mode 100644 index 0000000000..6586222eae --- /dev/null +++ b/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift @@ -0,0 +1,166 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import LithePluginPackageSigning +import Testing +@testable import Lithe + +@Suite("Remote plugin deployment") +struct RemotePluginDeploymentTests { + @Test + func deploysApprovedBytesReadOnlyAndReleasesOriginalHost() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + let deployer = fixture.deployer(host) + let before = try PluginPackageSignature.contentDigest(packageAt: fixture.package.packageURL) + let result = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "original") + #expect(result.packageURL.path.hasPrefix("/host/.lithe-server/plugin-runs/")) + #expect(host.uploads.count == 2) + #expect(host.uploads.contains(where: { $0.mode == 0o500 && $0.target.hasSuffix("/server/run") })) + #expect(host.uploads.contains(where: { $0.mode == 0o400 && $0.target.hasSuffix("/plugin.json") })) + #expect(host.removals.isEmpty) + await result.release() + #expect(host.removals.count == 1) + #expect(host.removals.first?.session == "original") + #expect(try PluginPackageSignature.contentDigest(packageAt: fixture.package.packageURL) == before) + } + + @Test + func transferFailureAndCancellationRemoveOwnedSnapshot() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + host.failsUpload = true + await #expect(throws: CocoaError.self) { + _ = try await fixture.deployer(host).deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "failed") + } + #expect(host.removals.map(\.session) == ["failed"]) + let blocked = DeploymentHostFixture() + blocked.blocksUpload = true + let deployer = fixture.deployer(blocked) + let completed = TestGate() + let task = Task { + defer { completed.open() } + return try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "cancelled") + } + defer { task.cancel(); blocked.release.open() } + try #require(await blocked.started.waitUntilOpen(timeout: .seconds(5))) + task.cancel() + try #require(await completed.waitUntilOpen(timeout: .seconds(5))) + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + #expect(blocked.removals.map(\.session) == ["cancelled"]) + #expect(!blocked.cleanupWasCancelled) + } + + @Test + func changedApprovedPackageAndUnsupportedTargetNeverUpload() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + host.facts = .init(os: "linux", arch: "aarch64", homeDirectory: "/host", features: ["upload"]) + let deployer = fixture.deployer(host) + await #expect(throws: DeclarativePluginRejection.self) { + _ = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "linux") + } + #expect(host.uploads.isEmpty) + host.facts = DeploymentHostFixture.currentHost + try Data("changed".utf8).write(to: fixture.package.packageURL.appendingPathComponent("server/run")) + await #expect(throws: PluginPackageStoreError.self) { + _ = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "changed") + } + #expect(host.uploads.isEmpty) + #expect(host.removals.isEmpty) + } + + @Test + func hostArchitectureUsesHandshakeFacts() { + #expect(RemotePluginHost(os: "linux", arch: "aarch64", homeDirectory: nil, features: []).platform == "linux-arm64") + #expect(RemotePluginHost(os: "linux", arch: "aarch64", homeDirectory: nil, features: []).templateArchitecture == "arm64") + #expect(RemotePluginHost(os: "macos", arch: "x86_64", homeDirectory: nil, features: []).platform == "darwin-x64") + #expect(RemotePluginHost(os: "linux", arch: "unknown", homeDirectory: nil, features: []).platform == nil) + } +} + +private struct DeploymentFixture { + let root: URL + let package: InstalledPluginPackage + var id: String { package.descriptor.id.rawValue } + + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-deploy-test-" + UUID().uuidString) + do { + let source = try DeclarativePluginTestSupport.writePackage(at: root.appendingPathComponent("source"), + manifest: DeclarativePluginTestSupport.manifestObject(version: .init(major: 1, minor: 0, patch: 0), + signatureRequirement: .unsigned, packagedLauncher: "server/run")) + let digest = try PluginPackageSignature.contentDigest(packageAt: source) + package = try MacPluginPackageStore(rootURL: root.appendingPathComponent("installed"), + validator: DeclarativePluginTestSupport.operations).installPackage(from: source, origin: .community, + disk: .init(fileName: "fixture.zip", archiveSHA256: nil, contentDigest: digest, approvedPrograms: [])) + } catch { try? FileManager.default.removeItem(at: root); throw error } + } + + func remove() { try? FileManager.default.removeItem(at: root) } + + func deployer(_ host: DeploymentHostFixture) -> MacRemotePluginPackageDeployer { + let root = root + return MacRemotePluginPackageDeployer(installed: [package], makeStore: { architecture in + MacPluginPackageStore(rootURL: root.appendingPathComponent("unused"), + validator: DeclarativePluginTestSupport.operations, architecture: architecture) + }, evaluator: RustPluginMarketplaceOperations(core: RustCoreBridge()), transport: NoDeploymentNetwork(), + host: host, temporaryDirectory: root) + } +} + +private struct NoDeploymentNetwork: MacPluginMarketplaceTransport { + func data(from url: URL, maximumBytes: Int64) async throws -> Data { + Issue.record("Disk approval must not acquire repository provenance") + throw CocoaError(.featureUnsupported) + } + func download(from url: URL, maximumBytes: Int64) async throws -> URL { + Issue.record("This test must not download packages") + throw CocoaError(.featureUnsupported) + } +} + +private final class DeploymentHostFixture: RemotePluginFileAccess, @unchecked Sendable { + struct Upload { let target: String; let mode: UInt32 } + struct Removal { let session: String } + static let currentHost = RemotePluginHost(os: "macos", + arch: MacPluginLanguageServerLaunchResolver.hostArchitecture == "arm64" ? "aarch64" : "x86_64", + homeDirectory: "/host", features: ["upload"]) + private let lock = NSLock() + // Scripted outcomes change only between awaited calls. + var facts = currentHost + var failsUpload = false + var blocksUpload = false + private var sent: [Upload] = [] + private var removed: [Removal] = [] + private var cancelledCleanup = false + let started = TestGate() + let release = TestGate() + var uploads: [Upload] { lock.withLock { sent } } + var removals: [Removal] { lock.withLock { removed } } + var cleanupWasCancelled: Bool { lock.withLock { cancelledCleanup } } + func pluginHost(session: String) async throws -> RemotePluginHost { facts } + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue { + if method == "fs.stat" { return .object(["kind": .string("directory"), "mode": .integer(0o700)]) } + if method == "fs.realPath" { return .object(["path": .string("/host/.lithe-server/plugin-runs")]) } + if method == "fs.remove" { + lock.withLock { removed.append(.init(session: session)); cancelledCleanup = Task.isCancelled } + } + return .null + } + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws { + #expect(try MacPluginPackageDownloader.sha256(of: source) == sha256) + lock.withLock { sent.append(.init(target: path, mode: mode)) } + if failsUpload { throw CocoaError(.fileWriteUnknown) } + if blocksUpload { + started.open() + _ = await release.waitUntilOpen(timeout: .seconds(5)) + try Task.checkCancellation() + } + } +} diff --git a/macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift b/macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift new file mode 100644 index 0000000000..5e53589391 --- /dev/null +++ b/macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift @@ -0,0 +1,168 @@ +import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule +import Testing +@testable import Lithe + +@Suite("Remote plugin launch preparation") +@MainActor +struct RemotePluginLaunchPreparerTests { + @Test + func realDeclarationExpandsWithTargetArchitectureAndRemoteWritableState() async throws { + let fixture = try LaunchFixture() + let tools = LaunchTools() + let result = try await fixture.preparer.prepareLanguage(fixture.descriptor, + workspaceURL: fixture.workspace, tools: tools, versionChecker: DeclarativePluginTestSupport.operations) + #expect(result.executableURL.path == "/host/bin/node") + #expect(tools.invocation?.executables == [.runtime("node")]) + #expect(tools.invocation?.runtimeDependencies.first?.minimumVersion == "18.17") + #expect(result.launch.arguments.contains("--max-old-space-size=4096")) + #expect(result.launch.arguments.contains("/host/package/language-server/lib/server.js")) + #expect(result.launch.initializationOptions == .object([ + "stubs": .array([.string("/host/package/language-server/stubs/arm64")]), "telemetry": .bool(false) + ])) + #expect(result.environment["OPAQUE"] == fixture.workspace.path) + #expect(fixture.host.created.count == 2) + #expect(fixture.host.created.allSatisfy { $0.hasPrefix("/host/.lithe-server/state/plugins/") }) + #expect(fixture.host.checked.allSatisfy { $0.hasPrefix("/host/package/") }) + #expect(fixture.host.released.isEmpty) + fixture.rebind() + await result.release() + #expect(fixture.host.released == ["original"]) + } + + @Test + func missingTargetResourceFailsAndReleasesTheDeployedSnapshot() async throws { + let fixture = try LaunchFixture() + fixture.host.missingResource = true + let tools = LaunchTools() + await #expect(throws: CocoaError.self) { + _ = try await fixture.preparer.prepareLanguage(fixture.descriptor, + workspaceURL: fixture.workspace, tools: tools, versionChecker: DeclarativePluginTestSupport.operations) + } + #expect(tools.invocation == nil) + #expect(fixture.host.created.isEmpty) + #expect(fixture.host.released == ["original"]) + } + + @Test + func reconnectDuringPreparationRejectsStaleLaunchAndReleasesOriginalHost() async throws { + let fixture = try LaunchFixture() + let tools = LaunchTools() + tools.onResolve = { fixture.rebind() } + await #expect(throws: CancellationError.self) { + _ = try await fixture.preparer.prepareLanguage(fixture.descriptor, + workspaceURL: fixture.workspace, tools: tools, versionChecker: DeclarativePluginTestSupport.operations) + } + #expect(fixture.host.released == ["original"]) + } + + @Test + func cancelledToolPreparationReleasesOriginalSnapshot() async throws { + let fixture = try LaunchFixture() + let tools = LaunchTools() + tools.blocked = true + let completed = TestGate() + let task = Task { + defer { completed.open() } + return try await fixture.preparer.prepareLanguage(fixture.descriptor, + workspaceURL: fixture.workspace, tools: tools, versionChecker: DeclarativePluginTestSupport.operations) + } + defer { task.cancel(); tools.release.open() } + try #require(await tools.started.waitUntilOpen(timeout: .seconds(5))) + task.cancel() + tools.release.open() + try #require(await completed.waitUntilOpen(timeout: .seconds(5))) + await #expect(throws: CancellationError.self) { _ = try await task.value } + #expect(fixture.host.released == ["original"]) + } +} + +@MainActor +private struct LaunchFixture { + let workspace = URL(fileURLWithPath: "/fixture/remote-placeholder") + let registry = RemoteWorkspaceRegistry() + let host: LaunchHost + let preparer: MacRemotePluginLaunchPreparer + let descriptor: LanguageServerLaunchDescriptor + + init() throws { + let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/node-runtime.plugin.json")) + let validated = try DeclarativePluginTestSupport.operations.validate(manifest: data) + let settings = InMemoryPluginSettings() + settings.setValue(.integer(4096), forSetting: "heapMB", plugin: validated.id.rawValue) + host = LaunchHost(manifest: validated.normalizedManifest) + let package = URL(fileURLWithPath: "/local/approved-package") + descriptor = .init(executableNames: ["node"], environment: ["OPAQUE": workspace.path], + launchSource: .init(pluginID: validated.id.rawValue, languageID: "fixture", + manifest: validated.normalizedManifest, packageURL: package)) + registry.register(.init(root: workspace, remoteRoot: "/host/project", sessionID: "original", connectionID: UUID())) + let operations = DeclarativePluginTestSupport.operations + preparer = .init(registry: registry, packages: host, host: host, planner: operations, + resolver: .init(planner: operations, + directories: .init(rootURL: URL(fileURLWithPath: "/unused/local-state")), settings: settings), core: RustCoreBridge()) + } + + func rebind() { + registry.register(.init(root: workspace, remoteRoot: "/replacement/project", sessionID: "replacement", connectionID: UUID())) + } +} + +private final class LaunchHost: RemotePluginFileAccess, RemotePluginPackageDeploying, @unchecked Sendable { + let manifest: Data + private let lock = NSLock() + private var releases: [String] = [] + var released: [String] { lock.withLock { releases } } + // Requests are awaited sequentially; tests inspect state after completion. + var missingResource = false + var checked: [String] = [] + var created: [String] = [] + init(manifest: Data) { self.manifest = manifest } + func buildRequiresPackage(pluginID: String, buildSystemID: String, packageURL: URL) throws -> Bool { true } + func deploy(pluginID: String, packageURL: URL, session: String) async throws -> RemoteDeployedPluginPackage { + .init(packageURL: URL(fileURLWithPath: "/host/package"), manifest: manifest, architecture: "arm64", + release: { self.lock.withLock { self.releases.append(session) } }) + } + func pluginHost(session: String) async throws -> RemotePluginHost { + #expect(session == "original") + return .init(os: "linux", arch: "aarch64", homeDirectory: "/host", features: ["upload"]) + } + func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue { + #expect(session == "original") + guard case .object(let values) = params, case .string(let path) = values["path"] else { + throw CocoaError(.fileReadCorruptFile) + } + if method == "fs.stat" { + checked.append(path) + return .object(["kind": .string(missingResource ? "missing" : "file")]) + } + #expect(method == "fs.createDir") + created.append(path) + return .null + } + func uploadPluginFile(_ source: URL, to path: String, sha256: String, mode: UInt32, session: String) async throws { + Issue.record("Launch planning must use the deployment port, never upload independently") + } +} + +@MainActor +private final class LaunchTools: BuildToolResolving { + var invocation: BuildInvocation? + var onResolve: (() -> Void)? + var blocked = false + let started = TestGate() + let release = TestGate() + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + self.invocation = invocation + #expect(try checkVersion(nil, "v20.3.0").satisfied) + onResolve?() + if blocked { + started.open() + _ = await release.waitUntilOpen(timeout: .seconds(5)) + try Task.checkCancellation() + } + return .init(executableURL: URL(fileURLWithPath: "/host/bin/node"), environment: [:], executesProjectCode: false, version: "20.3.0") + } +} diff --git a/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift b/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift new file mode 100644 index 0000000000..8e8ab7e7b3 --- /dev/null +++ b/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift @@ -0,0 +1,161 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Remote runtime discovery") +struct RemoteRuntimeDiscoveryTests { + private static let binding = RemoteWorkspaceBinding( + root: URL(fileURLWithPath: "/fixture/remote-placeholder"), remoteRoot: "/host/project", + sessionID: "fixture-host", connectionID: UUID() + ) + + @Test + func discoversHostToolsAndNormalizesOnlyAgainstRemoteHome() throws { + let host = FixtureRuntimeHost() + let locator = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + let result = locator.discover() + #expect(result.javaRuntimes.map(\.homePath) == ["/host/jdk"]) + #expect(result.mavenRuntimes.map(\.version) == ["3.9.9"]) + #expect(locator.discoveryFailure == nil) + #expect(locator.normalizedPath("~/jdk", relativeTo: Self.binding.root) == "/host/home/jdk") + #expect(locator.normalizedPath("tools/java", relativeTo: Self.binding.root) == "/host/project/tools/java") + #expect(locator.normalizedPath(Self.binding.root.path + "/gradlew", relativeTo: nil) == "/host/project/gradlew") + #expect(locator.isExecutable(at: URL(fileURLWithPath: "/host/bin/node"))) + #expect(!locator.isExecutable(at: URL(fileURLWithPath: "/host/bin/not-executable"))) + #expect(!locator.isExecutable(at: URL(fileURLWithPath: "/host/bin/not-executable-link"))) + #expect(locator.validJavaHome(path: "/host/jdk")?.path == "/host/jdk") + #expect(locator.validJavaHome(path: "/fixture/local-jdk") == nil) + let calls = host.callCount + let selection = try #require(locator.selectJavaRuntime( + candidates: [AutomaticJavaCandidate(id: "old", version: "17", priority: 0), + AutomaticJavaCandidate(id: "current", version: "21", priority: 1)], fallbackID: "old" + )).get() + #expect(selection.id == "current") + #expect(host.callCount == calls) + } + + @Test + func failedAndCancelledDiscoveryCannotPublishPartialOrLocalTools() { + let host = FixtureRuntimeHost(failsVersion: true) + let locator = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + #expect(locator.discover().javaRuntimes.isEmpty) + #expect(locator.discoveryFailure != nil) + #expect(locator.environment().isEmpty) + #expect(locator.candidates(for: "node", projectURL: Self.binding.root, environment: ["PATH": "/host/bin"]).isEmpty) + let calls = host.callCount + #expect(locator.discover(isCancelled: { true }).javaRuntimes.isEmpty) + #expect(host.callCount == calls) + } + + @Test + func invalidatedProbeCannotOverwriteANewerSnapshot() { + let host = FixtureRuntimeHost() + let locator = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + host.beforeVersion = { locator.invalidateProbeCache() } + #expect(locator.discover().javaRuntimes.isEmpty) + #expect(locator.environment().isEmpty) + host.beforeVersion = nil + #expect(locator.discover().javaRuntimes.count == 1) + } + + @Test @MainActor + func projectSwitchUsesHostWorkersAndNewProjectAlwaysUsesLocalDiscovery() async { + let host = FixtureRuntimeHost() + let remote = MacRemoteRuntimeLocator(binding: Self.binding, host: host) + let local = FixtureLocalRuntimeLocator() + let service = ProjectRuntimeService(runtimeLocator: local, store: FixtureRuntimeStore(), + workspaceRuntime: { $0 == Self.binding.root ? (remote, remote) : nil }) + defer { service.closeProject() } + service.openProject(at: Self.binding.root) + await service.ensureRuntimesDiscovered() + #expect(service.javaRuntimes.map(\.homePath) == ["/host/jdk"]) + #expect(service.javaHomeURL()?.path == "/host/jdk") + #expect(!host.probedOnMainThread) + let hostCalls = host.callCount + let newProject = await service.discoverJavaRuntimesForNewProject() + #expect(newProject.map(\.homePath) == ["/fixture/local-jdk"]) + #expect(host.callCount == hostCalls) + service.openProject(at: URL(fileURLWithPath: "/fixture/local-project")) + await service.ensureRuntimesDiscovered() + #expect(service.javaHomeURL()?.path == "/fixture/local-jdk") + #expect(host.callCount == hostCalls) + } +} + +private final class FixtureRuntimeHost: RemoteRuntimeProbing, @unchecked Sendable { + private let lock = NSLock() + private var calls = 0 + private var onMain = false + private var versionHook: (@Sendable () -> Void)? + let failsVersion: Bool + init(failsVersion: Bool = false) { self.failsVersion = failsVersion } + var callCount: Int { lock.withLock { calls } } + var probedOnMainThread: Bool { lock.withLock { onMain } } + var beforeVersion: (@Sendable () -> Void)? { + get { lock.withLock { versionHook } } + set { lock.withLock { versionHook = newValue } } + } + private func record(_ session: String) { + #expect(session == "fixture-host") + lock.withLock { calls += 1; onMain = onMain || Thread.isMainThread } + } + func runtimeEnvironment(session: String) throws -> [String: String] { + record(session) + return ["HOME": "/host/home", "JAVA_HOME": "/host/jdk", "PATH": "/host/bin"] + } + func runtimeRequirements(_ root: String, session: String) throws -> ToolingJSONValue { + record(session) + #expect(root == "/host/project") + return .object(["version": .integer(1), "toolchains": .object([ + "project-jdk": .object(["type": .string("java"), "minimumVersion": .string("21")]) + ])]) + } + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] { + record(session) + guard path == "/host/bin" else { return [] } + return [ + RemoteDirectoryEntry(name: "java", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o755)), + RemoteDirectoryEntry(name: "mvn", metadata: .init(kind: "file", mode: 0o755)), + RemoteDirectoryEntry(name: "node", metadata: .init(kind: "file", mode: 0o755)), + RemoteDirectoryEntry(name: "not-executable", metadata: .init(kind: "file", mode: 0o644)), + RemoteDirectoryEntry(name: "not-executable-link", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o644)) + ] + } + func runtimeRealPath(_ path: String, session: String) throws -> String { + record(session) + return path == "/host/bin/java" ? "/host/jdk/bin/java" : path + } + func runtimeExecutable(_ path: String, session: String) throws -> Bool { + record(session) + return ["/host/jdk/bin/java", "/host/project/gradlew"].contains(path) + } + func runtimeVersion(_ executable: String, session: String) throws -> String { + record(session) + beforeVersion?() + if failsVersion { throw RemoteProbeFailure(code: "connection_closed", message: "fixture disconnected") } + return executable.hasSuffix("/java") ? "openjdk version \"21.0.1\"\nOpenJDK Runtime Environment" : "Apache Maven 3.9.9" + } +} + +private struct FixtureRuntimeStore: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} +} + +private struct FixtureLocalRuntimeLocator: RuntimeLocator { + private let home = URL(fileURLWithPath: "/fixture/local-jdk") + func environment() -> [String: String] { [:] } + func discover() -> RuntimeDiscoveryResult { + RuntimeDiscoveryResult(javaRuntimes: [JavaRuntimeCandidate(homePath: home.path, version: "21", vendor: "fixture")], mavenRuntimes: []) + } + func validJavaHome(path: String) -> URL? { path == home.path ? home : nil } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { discover().javaRuntimes.first } + func isExecutable(at url: URL) -> Bool { false } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } +} diff --git a/macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift b/macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift new file mode 100644 index 0000000000..e0a6a1e4ea --- /dev/null +++ b/macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Remote shell discovery") +struct RemoteShellDiscoveryTests { + @Test + func usesOnlyHostPathsAndExecutablePermissionsInStableOrder() throws { + let host = ShellHost() + let shells = try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { false }) + #expect(shells == ["/host/bin/fish", "/host/bin/bash", "/host/bin/sh"]) + #expect(host.directories == ["/host/bin"]) + #expect(host.probedExecutables == ["/host/bin/sh"]) + #expect(host.sessions.allSatisfy { $0 == "host-one" }) + } + + @Test + func discoveryFailureDoesNotReturnAPartialHostSnapshot() { + let host = ShellHost(failsDirectory: true) + #expect(throws: (any Error).self) { + try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { false }) + } + } + + @Test + func cancellationStopsBeforeAnyHostRequest() { + let host = ShellHost() + #expect(throws: CancellationError.self) { + try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { true }) + } + #expect(host.sessions.isEmpty) + } + + @Test + func emptyHostEnvironmentDoesNotSearchMacSystemDirectories() throws { + let host = ShellHost(emptyEnvironment: true) + #expect(try MacRemoteShellDiscovery.availableShells(session: "host-one", host: host, isCancelled: { false }).isEmpty) + #expect(host.directories.isEmpty) + } +} + +/// Used synchronously on the test's thread; the async worker's host is tested +/// through its protocol elsewhere. No real host or installed shell is required. +private final class ShellHost: RemoteShellProbing, @unchecked Sendable { + var directories: [String] = [] + var probedExecutables: [String] = [] + var sessions: [String] = [] + let failsDirectory: Bool + let emptyEnvironment: Bool + init(failsDirectory: Bool = false, emptyEnvironment: Bool = false) { + self.failsDirectory = failsDirectory + self.emptyEnvironment = emptyEnvironment + } + func shellEnvironment(session: String) throws -> [String: String] { + sessions.append(session) + return emptyEnvironment ? [:] : ["SHELL": "/host/bin/fish", "PATH": "/host/bin:/host/bin:relative"] + } + func registeredShells(session: String) throws -> String { + sessions.append(session) + return emptyEnvironment ? "" : "# host shells\n/host/bin/fish\n/host/bin/bash\n/host/bin/nu\nrelative" + } + func runtimeDirectory(_ path: String, session: String) throws -> [RemoteDirectoryEntry] { + sessions.append(session) + directories.append(path) + if failsDirectory { throw RemoteProbeFailure(code: "connection_closed", message: "fixture disconnected") } + return [ + .init(name: "fish", metadata: .init(kind: "file", mode: 0o755)), + .init(name: "bash", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o755)), + .init(name: "nu", metadata: .init(kind: "file", mode: 0o644)), + .init(name: "sh", metadata: .init(kind: "symlink", symlinkTargetKind: "file")), + .init(name: "dash", metadata: .init(kind: "symlink", symlinkTargetKind: "file", symlinkTargetMode: 0o644)), + .init(name: "zsh", metadata: .init(kind: "directory", mode: 0o755)), + ] + } + func runtimeExecutable(_ path: String, session: String) throws -> Bool { + sessions.append(session) + probedExecutables.append(path) + return path == "/host/bin/sh" + } +} diff --git a/macos/Tests/LitheTests/RemoteTerminalTransportTests.swift b/macos/Tests/LitheTests/RemoteTerminalTransportTests.swift new file mode 100644 index 0000000000..36412e0281 --- /dev/null +++ b/macos/Tests/LitheTests/RemoteTerminalTransportTests.swift @@ -0,0 +1,202 @@ +import Foundation +import LitheTerminalModule +import SwiftTerm +import Testing +@testable import Lithe + +@Suite("Remote terminal transport") @MainActor +struct RemoteTerminalTransportTests { + private let root = URL(fileURLWithPath: "/fixture/placeholder") + + private func terminal(_ host: FixtureTerminalHost) -> (MacRemoteTerminalTransport, MacTerminalTransport) { + let renderer = MacTerminalTransport(scrollbackLines: 100) + let binding = RemoteWorkspaceBinding(root: root, remoteRoot: "/host/project", + sessionID: "host-one", connectionID: UUID()) + return (MacRemoteTerminalTransport(renderer: renderer, binding: binding, transport: host), renderer) + } + + @Test + func managedLaunchUsesTheWorkspaceHostAndKeepsArgumentsSeparate() async throws { + let host = FixtureTerminalHost() + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + let launch = TerminalProcessLaunch(title: "Run", executablePath: "/host/jdk/bin/java", + arguments: ["-cp", "/host/project/classes with spaces", "Main"], + workingDirectory: root.appendingPathComponent("module").path) + let pid = try await transport.startProcessAsync(launch, environment: ["TERM": "xterm-256color"]) + #expect(pid == 321) + #expect(renderer.view.process?.running != true) + #expect(renderer.view.hasRemoteProcess) + #expect(host.requests.first?.session == "host-one") + #expect(host.requests.first?.request.cwd == "/host/project/module") + #expect(host.requests.first?.request.argv == ["/host/jdk/bin/java", "-cp", "/host/project/classes with spaces", "Main"]) + try transport.send(Data("input\n".utf8)) + #expect(await terminalEventually { !host.writes.isEmpty }) + #expect(host.writes.first == Data("input\n".utf8)) + await transport.stopAndWait() + #expect(host.activeRuns == 0) + #expect(!renderer.view.hasRemoteProcess) + } + + @Test + func shellUsesRemoteDefaultAndCoalescesResizeWhileControlIsPending() async throws { + let host = FixtureTerminalHost(holdsWrites: true) + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + try await transport.startShell(workingDirectory: root.path, shellPath: transport.defaultShellPath(), environment: [:]) + #expect(host.requests.first?.request.argv == nil) + #expect(host.requests.first?.request.cwd == "/host/project") + try transport.send(Data([1])) + #expect(await terminalEventually { !host.writes.isEmpty }) + let previous = host.sizes.count + for column in 40...140 { + renderer.view.terminalDelegate?.sizeChanged(source: renderer.view, newCols: column, newRows: 50) + } + #expect(host.sizes.count == previous) + host.releaseWrite() + #expect(await terminalEventually { host.sizes.count > previous }) + #expect(host.sizes.count == previous + 1) + #expect(host.sizes.last?.columns == 140) + #expect(host.sizes.last?.rows == 50) + await transport.stopAndWait() + #expect(host.activeRuns == 0) + } + + @Test + func stoppingBeforeAcknowledgementDoesNotPublishTheManagedSession() async throws { + let host = FixtureTerminalHost(acknowledgesStart: false) + let (transport, _) = terminal(host) + let feature = TerminalFeatureModel(terminalFactory: { transport }, workspaceTerminalFactory: { _ in transport }) + defer { feature.stopAllSessions(); host.finish() } + let started = Task { @MainActor in + try await feature.createProcessSessionAsync( + TerminalProcessLaunch(title: nil, executablePath: "/host/bin/tool", arguments: [], workingDirectory: "/host/project"), + workspaceURL: root) + } + #expect(await terminalEventually { host.activeRuns == 1 }) + #expect(feature.hasActiveSessions) + await feature.stopAllSessionsAndWait() + do { + _ = try await started.value + Issue.record("A stopped launch must not publish a terminal") + } catch is CancellationError {} catch { Issue.record("Unexpected launch failure: \(error)") } + #expect(feature.terminalSessions.isEmpty) + #expect(!feature.hasActiveSessions) + #expect(host.activeRuns == 0) + } + + @Test + func exitedTerminalReleasesItsRemoteSurfaceAndNotifiesOnce() async throws { + let host = FixtureTerminalHost() + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + var exits: [Int32?] = [] + transport.onTermination = { exits.append($0) } + try await transport.startShell(workingDirectory: root.path, shellPath: "/host/bin/fish", environment: [:]) + #expect(host.requests.first?.request.argv == ["/host/bin/fish", "-l", "-i"]) + host.finish(exit: 7) + #expect(await terminalEventually { host.activeRuns == 0 }) + #expect(!transport.isRunning) + #expect(transport.processID == nil) + #expect(!renderer.view.hasRemoteProcess) + #expect(exits.count == 1) + #expect(exits.first == 7) + await transport.stopAndWait() + #expect(exits.count == 1) + } + + @Test + func endedConnectionReleasesTerminalIdentityWithoutAnExitEvent() async throws { + let host = FixtureTerminalHost(holdsWrites: true) + let (transport, renderer) = terminal(host) + defer { transport.stop(); host.finish() } + var exits: [Int32?] = [] + transport.onTermination = { exits.append($0) } + try await transport.startShell(workingDirectory: root.path, shellPath: transport.defaultShellPath(), environment: [:]) + try transport.send(Data([1])) + #expect(await terminalEventually { !host.writes.isEmpty }) + + // The connection finishes without terminal.exit while an input control + // is pending. Neither its PID nor that control may survive teardown. + host.finish() + #expect(await terminalEventually { exits.count == 1 }) + #expect(exits.first == .some(nil)) + #expect(!transport.isRunning) + #expect(transport.processID == nil) + #expect(!renderer.view.hasRemoteProcess) + await transport.stopAndWait() + #expect(host.activeRuns == 0) + #expect(exits.count == 1) + } +} + +@MainActor +private func terminalEventually(_ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + .seconds(3) + while !condition() { + guard ContinuousClock.now < deadline else { return false } + await Task.yield() + } + return true +} + +private final class FixtureTerminalHost: RemoteTerminalConnecting, @unchecked Sendable { + struct Request { let session: String; let request: RemoteTerminalRequest } + struct Size { let columns: UInt16; let rows: UInt16 } + private let lock = NSLock() + private var recordedRequests: [Request] = [] + private var recordedWrites: [Data] = [] + private var recordedSizes: [Size] = [] + private var runs = 0 + private var observer: (@Sendable (RemoteTerminalEvent) -> Void)? + private var end: AsyncStream.Continuation? + private let writeStream: AsyncStream + private let writeContinuation: AsyncStream.Continuation + let acknowledgesStart: Bool + let holdsWrites: Bool + var requests: [Request] { lock.withLock { recordedRequests } } + var writes: [Data] { lock.withLock { recordedWrites } } + var sizes: [Size] { lock.withLock { recordedSizes } } + var activeRuns: Int { lock.withLock { runs } } + + init(acknowledgesStart: Bool = true, holdsWrites: Bool = false) { + self.acknowledgesStart = acknowledgesStart + self.holdsWrites = holdsWrites + (writeStream, writeContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + } + + func runTerminal(_ request: RemoteTerminalRequest, session: String, + onEvent: @escaping @Sendable (RemoteTerminalEvent) -> Void) async -> Result { + let (stream, continuation) = AsyncStream.makeStream() + lock.withLock { + recordedRequests.append(Request(session: session, request: request)) + runs += 1 + observer = onEvent + end = continuation + } + defer { lock.withLock { runs -= 1; observer = nil; end = nil } } + if acknowledgesStart { onEvent(.started(id: 17, pid: 321)) } + var iterator = stream.makeAsyncIterator() + _ = await iterator.next() + return .success(()) + } + func writeTerminal(_ data: Data, id: UInt64, session: String) async throws { + #expect(id == 17 && session == "host-one") + lock.withLock { recordedWrites.append(data) } + if holdsWrites { + var iterator = writeStream.makeAsyncIterator() + _ = await iterator.next() + } + } + func resizeTerminal(id: UInt64, columns: UInt16, rows: UInt16, session: String) async throws { + #expect(id == 17 && session == "host-one") + lock.withLock { recordedSizes.append(Size(columns: columns, rows: rows)) } + } + func releaseWrite() { writeContinuation.yield() } + func finish(exit: Int32? = nil) { + let (callback, continuation) = lock.withLock { (self.observer, self.end) } + if let exit { callback?(.exited(exit)) } + continuation?.finish() + writeContinuation.finish() + } +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d15dd23d97..eaa3eeba14 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1380,7 +1380,7 @@ dependencies = [ "nix", "tokio", "tracing", - "windows", + "windows 0.52.0", ] [[package]] @@ -1482,6 +1482,7 @@ dependencies = [ "lithe-ide-host", "lithe-remote-client", "lithe-remote-protocol", + "lithe-remote-server", "quick-xml", "rand 0.8.7", "regex", @@ -1556,6 +1557,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "trash", ] [[package]] @@ -1719,6 +1721,31 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3068,6 +3095,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "5.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.62.2", +] + [[package]] name = "tree-sitter" version = "0.25.10" @@ -3184,6 +3229,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3390,6 +3441,27 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.52.0" @@ -3412,6 +3484,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3440,6 +3523,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3492,6 +3585,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index d331e6eb4f..e8f9602ec4 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -35,3 +35,6 @@ tree-sitter-java = "0.23" serde_yaml_ng = "0.10.0" zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" + +[target.'cfg(unix)'.dev-dependencies] +lithe-remote-server = { path = "../lithe-remote-server" } diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index e8d7181236..296f20fa24 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -32,6 +32,9 @@ struct BuildPayload { manifest: Value, #[serde(rename = "buildSystemID")] build_system_id: String, + /// Routing identity for plans that otherwise contain only relative paths. + #[serde(default, rename = "workspaceRoot")] + _workspace_root: Option, request: T, } diff --git a/rust/lithe-core/src/build/decode.rs b/rust/lithe-core/src/build/decode.rs index b20c1174c5..1ce589a637 100644 --- a/rust/lithe-core/src/build/decode.rs +++ b/rust/lithe-core/src/build/decode.rs @@ -7,8 +7,8 @@ //! outside the workspace. use crate::plugins::declarative::{PluginRejection, Validation}; +use crate::remote::{workspace, workspace_io as fs}; use serde_json::Value; -use std::fs; use std::path::{Component, Path, PathBuf}; use std::time::UNIX_EPOCH; @@ -108,10 +108,26 @@ impl Workspace { )); } let mut roots = vec![slashed(root)]; - if let Ok(canonical) = path.canonicalize() { + let resolved = fs::canonicalize(&path); + if workspace::is_remote() && resolved.is_err() { + return Err(invalid_output( + "The workspace host could not resolve the build root.", + )); + } + if let Ok(canonical) = &resolved { roots.push(slashed(&canonical.to_string_lossy())); } - let canonical = path.canonicalize().unwrap_or(path); + let canonical = resolved.unwrap_or(path); + if workspace::is_remote() { + // Tool output contains host paths, while file adapters retain the + // native placeholder identity for containment and navigation. + roots.push(slashed( + &workspace::remote_path(&canonical).to_string_lossy(), + )); + roots.push(slashed( + &workspace::remote_path(Path::new(root)).to_string_lossy(), + )); + } Ok(Self { roots, path: canonical, @@ -129,7 +145,7 @@ impl Workspace { /// Resolves an existing workspace path without following links outside it. pub(crate) fn read_path(&self, relative: &str) -> Option { - let path = self.join(relative).canonicalize().ok()?; + let path = fs::canonicalize(self.join(relative)).ok()?; path.starts_with(&self.path).then_some(path) } @@ -161,8 +177,13 @@ impl Workspace { /// after checking existence and resolves links again before navigating. pub(crate) fn relativize_reported(&self, raw: &str, base: &str) -> Option { let relative = self.relativize(raw, base)?; - match self.join(&relative).canonicalize() { + match fs::canonicalize(self.join(&relative)) { Ok(resolved) if !resolved.starts_with(&self.path) => None, + Err(error) + if workspace::is_remote() && error.kind() != std::io::ErrorKind::NotFound => + { + None + } _ => Some(relative), } } diff --git a/rust/lithe-core/src/build/integrations/maven.rs b/rust/lithe-core/src/build/integrations/maven.rs index b29560e124..5128469ee3 100644 --- a/rust/lithe-core/src/build/integrations/maven.rs +++ b/rust/lithe-core/src/build/integrations/maven.rs @@ -232,7 +232,7 @@ fn split_failure_name(name: &str) -> (String, String) { pub(super) fn test_results( workspace: &Workspace, - _root: &str, + root: &str, project: &str, request: &DecodeTestResultsRequest, ) -> Validation { @@ -250,8 +250,36 @@ pub(super) fn test_results( reports, }) .map_err(rejected)?; - let mut cases: Vec = response - .test_cases + let mut reported_cases = response.test_cases; + if let Some(not_before_millis) = request.not_before_millis { + // Reactor runs write reports in each module, never just the aggregator. + // Keep text parsing once while reusing Maven's report locations per module. + for directory in super::super::outputs::report_directories(root, project, request)? { + if directory == project { + continue; + } + let module = maven_test_results(MavenTestResultsRequest { + root: workspace.path.to_string_lossy().into_owned(), + output: String::new(), + reports: Some(MavenTestReportsRequest { + module: Some(directory), + source_path: None, + classes: request.suite.iter().cloned().collect(), + not_before_millis, + }), + }) + .map_err(rejected)?; + if reported_cases.len() + module.test_cases.len() + > super::super::outputs::MAX_TEST_CASES + { + return Err(decode::invalid_output( + "The reactor has too many test cases.", + )); + } + reported_cases.extend(module.test_cases); + } + } + let mut cases: Vec = reported_cases .into_iter() .map(|case| TestCaseResult { suite: case.class_name, @@ -293,14 +321,12 @@ pub(super) fn test_results( }), } } - Ok(TestResults { - cases, - summary: Some(TestSummary { - total: response.tests_run, - passed: response.passed, - failed: response.failures, - errors: response.errors, - skipped: response.skipped, - }), - }) + let summary = (response.tests_run > 0 || cases.is_empty()).then_some(TestSummary { + total: response.tests_run, + passed: response.passed, + failed: response.failures, + errors: response.errors, + skipped: response.skipped, + }); + Ok(TestResults { cases, summary }) } diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 4fddc96485..0c4c964053 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -114,11 +114,17 @@ pub(crate) fn invocation_plan( manifest: &DeclarativePluginManifest, ) -> Validation { let root = decode::request_path(&request.root, "root")?; + let platform = crate::remote::workspace::current() + .map(|host| match host.connection.session.hello().host.os { + lithe_remote_protocol::methods::OperatingSystem::Linux => Platform::Linux, + lithe_remote_protocol::methods::OperatingSystem::Macos => Platform::Macos, + }) + .unwrap_or(request.platform); let invocation = &build_system.invocation; let mut executables = candidates( &invocation.executables, &root, - request.platform, + platform, manifest, &request.settings, )?; @@ -143,7 +149,7 @@ pub(crate) fn invocation_plan( "runtimeDependencies": invocation .runtime_dependencies .iter() - .map(|dependency| runtime(dependency, &root, request.platform, manifest, &request.settings)) + .map(|dependency| runtime(dependency, &root, platform, manifest, &request.settings)) .collect::>>()?, "version": invocation.version.as_ref().map(|probe| json!({ "arguments": probe.arguments })), "minimumVersion": invocation.minimum_version, diff --git a/rust/lithe-core/src/build/language_import.rs b/rust/lithe-core/src/build/language_import.rs index 5d0afd8352..9b84b4dc6a 100644 --- a/rust/lithe-core/src/build/language_import.rs +++ b/rust/lithe-core/src/build/language_import.rs @@ -81,9 +81,9 @@ pub(crate) fn language_import( let mut gradle = Map::new(); gradle.insert( "wrapperPresent".into(), - json!(workspace - .read_path(&wrapper) - .is_some_and(|path| path.is_file())), + json!(workspace.read_path(&wrapper).is_some_and(|path| { + crate::remote::workspace_io::is_file(path).unwrap_or(false) + })), ); gradle.insert( "offline".into(), diff --git a/rust/lithe-core/src/build/model.rs b/rust/lithe-core/src/build/model.rs index 8e4fc2074a..b1891248da 100644 --- a/rust/lithe-core/src/build/model.rs +++ b/rust/lithe-core/src/build/model.rs @@ -544,8 +544,8 @@ fn when_file_holds(builder: &ModelBuilder<'_>, rule: &impl RuleMatch, directory: rule.when_file().is_none_or(|file| { builder .workspace - .join(&decode::join_relative(directory, file)) - .is_file() + .read_path(&decode::join_relative(directory, file)) + .is_some_and(|path| crate::remote::workspace_io::is_file(path).unwrap_or(false)) }) } diff --git a/rust/lithe-core/src/build/outputs.rs b/rust/lithe-core/src/build/outputs.rs index 4657b35e84..085f224498 100644 --- a/rust/lithe-core/src/build/outputs.rs +++ b/rust/lithe-core/src/build/outputs.rs @@ -27,7 +27,7 @@ use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -const MAX_TEST_CASES: usize = 10_000; +pub(super) const MAX_TEST_CASES: usize = 10_000; const MAX_DIAGNOSTICS: usize = 10_000; const MAX_DEPENDENCIES: usize = 10_000; const MAX_DEPENDENCY_DEPTH: usize = 64; @@ -171,7 +171,11 @@ pub(crate) fn source_marker( .markers .iter() .map(|marker| join_relative(root, marker)) - .find(|path| workspace.read_path(path).is_some_and(|path| path.is_file())) + .find(|path| { + workspace + .read_path(path) + .is_some_and(|path| crate::remote::workspace_io::is_file(path).unwrap_or(false)) + }) } /// The model with the run entries derived from it. @@ -401,7 +405,7 @@ pub(crate) fn decode_test_results( /// Directories whose reports belong to the run: the selected project, or the /// root and every model project inside it, deepest first so a file two globs /// reach is read relative to its own project. -fn report_directories( +pub(super) fn report_directories( root: &str, directory: &str, request: &DecodeTestResultsRequest, diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index 87910c5954..b8bea74a11 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -2823,16 +2823,19 @@ pub(super) fn read_requirements( .join(".lithe") .join("toolchains") .join("requirements.json"); - if !path.exists() { - return Ok(None); - } - let text = fs::read_to_string(path).map_err(|error| { - CoreError::new( - ErrorCode::PermissionDenied, - "Could not read toolchain requirements: .lithe/toolchains/requirements.json", - ) - .with_details(error.to_string()) - })?; + let text = match crate::remote::workspace_io::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err({ + CoreError::new( + ErrorCode::PermissionDenied, + "Could not read toolchain requirements: .lithe/toolchains/requirements.json", + ) + .with_details(error.to_string()) + }) + } + }; serde_json::from_str(&text).map(Some).map_err(|e| { CoreError::new( ErrorCode::ParseFailed, diff --git a/rust/lithe-core/src/execution/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs index b54db4b383..20bdf9d4df 100644 --- a/rust/lithe-core/src/execution/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -1,8 +1,8 @@ //! Bounded workspace traversal shared by all run-configuration detectors. use crate::protocol::{CoreError, ErrorCode}; +use crate::remote::workspace_io as fs; use std::collections::BTreeSet; -use std::fs; use std::path::{Path, PathBuf}; /// Detectors only care about manifests near the top of a project. Anything @@ -68,8 +68,11 @@ pub struct DirectoryContext { impl DirectoryContext { /// Captures the readable non-directory entries immediately below `path`. pub fn at(root: &Path, path: &Path) -> Result, CoreError> { - let Ok(entries) = fs::read_dir(path) else { - return Ok(None); + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(_) if !crate::remote::workspace::is_remote() => return Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), }; let files = entries .flatten() @@ -144,8 +147,13 @@ pub fn scan(root: &Path) -> Result, CoreError> { if results.len() >= MAX_DIRECTORIES { break; } - let Ok(entries) = fs::read_dir(&directory) else { - continue; + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + // Local detection has historically skipped unreadable subtrees. + // A remote transport/permission error must not publish an empty model. + Err(_) if !crate::remote::workspace::is_remote() => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), }; let mut files = BTreeSet::new(); let mut children = Vec::new(); diff --git a/rust/lithe-core/src/execution/java_selection.rs b/rust/lithe-core/src/execution/java_selection.rs index 4997fdc964..c7370a2f65 100644 --- a/rust/lithe-core/src/execution/java_selection.rs +++ b/rust/lithe-core/src/execution/java_selection.rs @@ -3,8 +3,9 @@ use super::configuration::{ java_feature_version_parts, read_requirements, version_parts, version_satisfies, + ToolchainRequirementsDocument, }; -use crate::protocol::CoreError; +use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -26,6 +27,9 @@ pub struct JavaSelectionCandidate { pub struct JavaSelectionRequest { /// Open workspace; absent means no project requirements. pub root: Option, + /// Host-read snapshot for UI-safe selection; mutually exclusive with `root`. + /// The same document validation and selection policy apply to both sources. + pub requirements: Option, /// Usable executable homes probed by the platform. pub candidates: Vec, /// The platform's unconstrained choice; used when requirements are absent @@ -61,12 +65,18 @@ pub fn compare_java_candidates( /// Reuses Core's requirement reader and version semantics without generating /// requirements, probing executables, or changing an explicit user selection. pub fn select_java(request: JavaSelectionRequest) -> Result { - let document = request + if request.root.is_some() && request.requirements.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Java selection must use either a workspace or a requirements snapshot", + )); + } + let document = request.requirements.or(request .root .as_deref() .map(read_requirements) .transpose()? - .flatten(); + .flatten()); if let Some(document) = &document { super::configuration::validate_sidecar_version(document.version)?; } diff --git a/rust/lithe-core/src/git/configuration.rs b/rust/lithe-core/src/git/configuration.rs index 45ecb2597f..bfb4c74e9f 100644 --- a/rust/lithe-core/src/git/configuration.rs +++ b/rust/lithe-core/src/git/configuration.rs @@ -338,7 +338,13 @@ pub(crate) fn dispatch(payload: Value, save: bool) -> Result { /// can echo the effective Git before a project is open. The version probe is /// best effort: a binary that starts but cannot report a version still stays /// selected. -pub(crate) fn locate_executable() -> Value { +pub(crate) fn locate_executable() -> Result { + if let Some(host) = crate::remote::workspace::current() { + let params = + super::remote_process::parameters(&host, "/", &["--version".into()], false, true, &[])?; + return Ok(json!({"executable": params.argv.first(), + "version": host.connection.git_tool.lock().unwrap_or_else(|error| error.into_inner()).as_ref().map(|(_, version)| version.clone())})); + } let selected = execution_policy::current().executable; let resolved = lithe_git_host::configuration::executable(selected.as_deref()); let version = resolved.as_ref().and_then(|path| { @@ -346,10 +352,10 @@ pub(crate) fn locate_executable() -> Value { .ok() .map(|version| version.trim().to_owned()) }); - json!({ + Ok(json!({ "executable": resolved.map(|path| path.to_string_lossy().into_owned()), "version": version, - }) + })) } fn inspect(root: &str, scope: &str) -> Result { @@ -386,14 +392,13 @@ fn inspect(root: &str, scope: &str) -> Result { .unwrap_or_else(|| json!({"scope": "application", "origin": null})); fetch_sources.insert(field.into(), source); } - Ok( - json!({ "executable": lithe_git_host::configuration::executable(options.executable.as_deref()).map(|path| path.to_string_lossy().into_owned()), + let executable = locate_executable()?["executable"].clone(); + Ok(json!({ "executable": executable, "version": String::from_utf8_lossy(&version).trim(), "scope": scope, "entries": entries, "fields": fields, "temporaryConfig": execution_policy::temporary_config(), "fetchSources": fetch_sources, "fetchOptions": effective_fetch.as_ref().ok(), "fetchError": effective_fetch.as_ref().err(), "credentialHelperEnabled": options.use_credential_helper, - "interactiveAuthentication": options.interactive }), - ) + "interactiveAuthentication": options.interactive })) } #[cfg(test)] @@ -410,7 +415,7 @@ mod tests { ..Default::default() }; let _scope = execution_policy::Scope::begin(Some(options)).expect("valid policy"); - let result = locate_executable(); + let result = locate_executable().unwrap(); assert!(result["executable"].is_null()); assert!(result["version"].is_null()); } diff --git a/rust/lithe-core/src/git/execution_events.rs b/rust/lithe-core/src/git/execution_events.rs index 027900a8d5..4d9e089576 100644 --- a/rust/lithe-core/src/git/execution_events.rs +++ b/rust/lithe-core/src/git/execution_events.rs @@ -191,6 +191,20 @@ impl Invocation { }) } pub(super) fn started(&self, root: &str, arguments: &[String]) { + let executable = lithe_git_host::configuration::executable( + super::execution_policy::current().executable.as_deref(), + ) + .map(|path| path.to_string_lossy().into_owned()); + self.started_on_host(root, arguments, executable); + } + + /// Reports the executable actually selected by a workspace host. + pub(super) fn started_on_host( + &self, + root: &str, + arguments: &[String], + executable: Option, + ) { let config = super::execution_policy::command_index(arguments) .is_some_and(|index| arguments[index] == "config"); let safe_config = arguments @@ -244,10 +258,7 @@ impl Invocation { &self.operation_id, Kind::Started { source: super::execution_policy::current().source, - executable: lithe_git_host::configuration::executable( - super::execution_policy::current().executable.as_deref(), - ) - .map(|path| path.to_string_lossy().into_owned()), + executable, temporary_config, display_arguments, global_arguments, diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 6c4f2852bb..d407d4c7b2 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -305,6 +305,7 @@ pub fn history_page(request: GitHistoryPageRequest) -> Result, reference: Option, /// Prevents a continuation from silently switching traversal policies. order: HistoryOrder, @@ -538,6 +545,27 @@ impl HistorySession { GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); + if let Some(remote) = super::remote_history::RemoteHistory::start(&root, &arguments)? { + return Ok(Self { + cursor: format!( + "git-history-cursor-{}", + NEXT_HISTORY_CURSOR.fetch_add(1, Ordering::Relaxed) + ), + host_key: crate::remote::workspace::identity_key(std::path::Path::new(&root)), + root, + reference, + order, + remote: Some(remote), + child: None, + receiver: None, + stdout_reader: None, + stderr_reader: None, + pending: None, + emitted: 0, + last_access: Instant::now(), + finished: false, + }); + } let mut process = git_process(); let mut child = process .args(&arguments) @@ -578,6 +606,8 @@ impl HistorySession { ); Ok(Self { cursor, + host_key: std::path::PathBuf::from(&root), + remote: None, root, reference, order, @@ -615,6 +645,15 @@ impl HistorySession { } fn next_commit(&mut self, deadline: Instant) -> Result, CoreError> { + if let Some(remote) = &mut self.remote { + while let Some(line) = remote.next_line(deadline)? { + if let Some(commit) = parse_commit(&line) { + return Ok(Some(commit)); + } + } + self.finish_process(false)?; + return Ok(None); + } loop { cancellation::check()?; if Instant::now() >= deadline { @@ -660,6 +699,7 @@ impl HistorySession { return Ok(()); } self.finished = true; + self.remote.take(); self.receiver.take(); let status = if let Some(mut child) = self.child.take() { if terminate { @@ -1121,6 +1161,8 @@ mod tests { fn finished_session(cursor: &str, last_access: Instant) -> HistorySession { HistorySession { + host_key: std::path::PathBuf::new(), + remote: None, cursor: cursor.to_string(), root: "/test/repository".to_string(), reference: None, diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index c6ccf1a867..92eca3b2d9 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -19,6 +19,8 @@ mod mutations; mod patch_exchange; mod progress; mod rebase_session; +mod remote_history; +mod remote_process; mod rewrite; mod setup; mod untracked_directories; @@ -63,11 +65,11 @@ use crate::protocol::{ GitWatchContextResponse, GitWorktreeResponse, GitWorktreesResponse, WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, }; +use crate::remote::workspace_io as fs; use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::collections::{HashSet, VecDeque}; use std::io::Read; -use std::io::Write; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; @@ -557,6 +559,20 @@ impl TemporaryGitCommitContext { } fn prepare_snapshot(head: Option<&str>) -> Result { + if let Some(directory) = + fs::remote_temp_directory("lithe-git-snapshot").map_err(line_history_io_error)? + { + let context = Self { + index_path: directory.join("index"), + directory, + temporary_reference: None, + }; + let contents = head + .map(|head| format!("{head}\n")) + .unwrap_or_else(|| "ref: refs/heads/lithe-snapshot\n".into()); + fs::write(context.directory.join("HEAD"), contents).map_err(line_history_io_error)?; + return Ok(context); + } // Creating even an ignored child inside .git changes its parent directory // on Windows. A read-only diff must not invalidate the Git watcher that // requested it; keep its disposable index outside repository metadata. @@ -567,16 +583,9 @@ impl TemporaryGitCommitContext { let (directory, sequence) = loop { let sequence = TEMPORARY_INDEX_SEQUENCE.fetch_add(1, Ordering::Relaxed); let candidate = parent.join(format!("{prefix}-{}-{sequence}", std::process::id())); - let mut builder = std::fs::DirBuilder::new(); - builder.recursive(false); // Public POSIX temp roots must not expose tracked paths/object IDs. // Set permissions at atomic creation, before writing HEAD or index. - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - builder.mode(0o700); - } - match builder.create(&candidate) { + match fs::create_dir_mode(&candidate, false, Some(0o700)) { Ok(()) => break (candidate, sequence), Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => { @@ -609,7 +618,7 @@ impl TemporaryGitCommitContext { directory, temporary_reference, }; - std::fs::write(context.directory.join("HEAD"), head_contents).map_err(|error| { + fs::write(context.directory.join("HEAD"), head_contents).map_err(|error| { CoreError::new( ErrorCode::Unknown, "Could not initialize an isolated Git commit context", @@ -656,7 +665,7 @@ impl TemporaryGitCommitContext { impl Drop for TemporaryGitCommitContext { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.directory); + fs::cleanup_directory(&self.directory); } } @@ -903,7 +912,7 @@ pub fn workspace_repositories( continue; } - let entries = std::fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; + let entries = fs::read_dir(&canonical_directory).map_err(repository_scan_error)?; let mut child_directories = Vec::new(); for entry in entries { crate::protocol::cancellation::check()?; @@ -1528,6 +1537,9 @@ fn write_with_trace(request: GitWriteRequest) -> Result { @@ -1749,6 +1761,16 @@ fn capture_git_process( ) -> Result { validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; crate::protocol::cancellation::check()?; + if let Some(result) = remote_process::capture( + root, + arguments, + input.as_deref(), + disable_optional_locks, + environment, + visible, + ) { + return result; + } let mut process = git_process(); let version = lithe_git_host::configuration::version(process.get_program(), || { crate::protocol::cancellation::check().is_err() @@ -3213,7 +3235,7 @@ fn git_directory(root: &str) -> Result, CoreError> { /// Reads a single-line numeric counter written by an in-progress rebase. fn rebase_counter(directory: &std::path::Path, name: &str) -> Option { - std::fs::read_to_string(directory.join(name)) + fs::read_to_string(directory.join(name)) .ok()? .trim() .parse() @@ -3240,22 +3262,26 @@ pub fn operation_state( if let Some(git_directory) = git_directory(&root)? { let rebase_merge = git_directory.join("rebase-merge"); let rebase_apply = git_directory.join("rebase-apply"); - let operation_directory = [rebase_merge, rebase_apply] - .into_iter() - .find(|path| path.exists()); + let operation_directory = if fs::try_exists(&rebase_merge)? { + Some(rebase_merge) + } else if fs::try_exists(&rebase_apply)? { + Some(rebase_apply) + } else { + None + }; if let Some(directory) = operation_directory { kind = "rebase".to_string(); step = rebase_counter(&directory, "msgnum"); total = rebase_counter(&directory, "end"); - reference = std::fs::read_to_string(directory.join("onto")) + reference = fs::read_to_string(directory.join("onto")) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); - } else if git_directory.join("MERGE_HEAD").exists() { + } else if fs::try_exists(git_directory.join("MERGE_HEAD"))? { kind = "merge".to_string(); - } else if git_directory.join("CHERRY_PICK_HEAD").exists() { + } else if fs::try_exists(git_directory.join("CHERRY_PICK_HEAD"))? { kind = "cherryPick".to_string(); - } else if git_directory.join("REVERT_HEAD").exists() { + } else if fs::try_exists(git_directory.join("REVERT_HEAD"))? { kind = "revert".to_string(); } } @@ -3463,7 +3489,7 @@ struct ScopedLineHistoryDirectory(std::path::PathBuf); impl Drop for ScopedLineHistoryDirectory { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + fs::cleanup_directory(&self.0); } } @@ -3471,6 +3497,11 @@ impl Drop for ScopedLineHistoryDirectory { /// snapshot may hold private source text, so POSIX permissions are applied at /// atomic creation exactly like the Git commit context directories. fn create_line_history_scratch() -> Result { + if let Some(directory) = + fs::remote_temp_directory("lithe-line-history").map_err(line_history_io_error)? + { + return Ok(ScopedLineHistoryDirectory(directory)); + } let prefix = format!("lithe-line-history-{}", std::process::id()); let mut attempt = 0u128; loop { @@ -3931,8 +3962,8 @@ pub fn line_history(request: GitLineHistoryRequest) -> Result std::io::Result std::io::Result { validate_windows_git_path(path)?; - path.canonicalize().and_then(simplified_canonical_path) + fs::canonicalize(path).and_then(simplified_canonical_path) } /// Missing worktrees may still be listed, but invalid path identities never fall back. @@ -4234,7 +4265,7 @@ fn git_path_error(error: std::io::Error) -> CoreError { fn validate_root(raw_root: &str) -> Result { let root = canonicalize_simplified(Path::new(raw_root)).map_err(git_path_error)?; - if !root.is_dir() { + if !fs::is_dir(&root)? { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, "Workspace does not exist", @@ -4533,7 +4564,7 @@ fn append_git_ignore_patterns( } if let Some(parent) = target_path.parent() { - std::fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; + fs::create_dir_all(parent).map_err(|error| git_ignore_io_error("create", error))?; } let mut appended = String::new(); if !existing.is_empty() && !existing.ends_with(b"\n") { @@ -4543,18 +4574,13 @@ fn append_git_ignore_patterns( appended.push_str(&pattern); appended.push('\n'); } - let mut file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(target_path) - .map_err(|error| git_ignore_io_error("open", error))?; - file.write_all(appended.as_bytes()) + fs::append(&target_path, appended.as_bytes()) .map_err(|error| git_ignore_io_error("write", error))?; Ok(successful_git_result()) } fn read_git_ignore_bytes(path: &Path) -> Result, CoreError> { - match std::fs::read(path) { + match fs::read(path) { Ok(content) => Ok(content), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), Err(error) => Err(git_ignore_io_error("read", error)), @@ -4637,7 +4663,7 @@ fn git_resolved_path(root: &str, arguments: &[&str], label: &str) -> Result CoreError { @@ -6151,7 +6177,7 @@ fn worktree_paths_match(left: &str, right: &str) -> bool { if left_path == right_path { return true; } - match (left_path.canonicalize(), right_path.canonicalize()) { + match (fs::canonicalize(&left_path), fs::canonicalize(&right_path)) { (Ok(left), Ok(right)) => left == right, _ => false, } @@ -6301,6 +6327,9 @@ fn create_worktree(root: &str, request: &GitWriteRequest) -> Result Result arguments.push("unlock".into()), _ => unreachable!("caller restricts worktree mutations"), } + let destination = crate::remote::workspace::remote_path(Path::new(&destination)) + .to_string_lossy() + .into_owned(); arguments.extend(["--".into(), destination]); execute_git(root, &arguments, None) } @@ -7688,7 +7720,7 @@ pub fn watch_context( request: GitWatchContextRequest, ) -> Result, CoreError> { let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; - if !root.is_dir() { + if !fs::is_dir(&root)? { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, "Workspace does not exist", @@ -7860,7 +7892,7 @@ fn branch_requires_publish(root: &str, branch: &str) -> bool { /// Returns the normalized repository status and branch context. pub fn status(request: GitStatusRequest) -> Result { let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; - if !root.is_dir() { + if !fs::is_dir(&root)? { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, "Workspace does not exist", @@ -8043,6 +8075,34 @@ fn pathspec_batches(paths: &[String], budget: usize) -> Vec<&[String]> { } fn run_git(directory: &Path, arguments: &[&str]) -> Result { + if crate::remote::workspace::is_remote() { + let output = capture_git_process( + &directory.to_string_lossy(), + &arguments + .iter() + .map(|value| value.to_string()) + .collect::>(), + None, + true, + &[], + false, + )?; + #[cfg(unix)] + let status = { + use std::os::unix::process::ExitStatusExt; + std::process::ExitStatus::from_raw(output.exit_code << 8) + }; + #[cfg(windows)] + let status = { + use std::os::windows::process::ExitStatusExt; + std::process::ExitStatus::from_raw(output.exit_code as u32) + }; + return Ok(std::process::Output { + status, + stdout: output.stdout, + stderr: output.stderr, + }); + } // Status and path discovery are read-only from Lithe's point of view. Git // may otherwise refresh its optional index data while answering a query, // which emits `.git/index` events into the native watcher and can trigger diff --git a/rust/lithe-core/src/git/patch_exchange.rs b/rust/lithe-core/src/git/patch_exchange.rs index b45d8e97ff..e344511f52 100644 --- a/rust/lithe-core/src/git/patch_exchange.rs +++ b/rust/lithe-core/src/git/patch_exchange.rs @@ -5,6 +5,7 @@ use super::{ operation_state, repository_root, resolve_commit_revision, validate_root, CoreError, ErrorCode, GitCommandResponse, GitOperationStateRequest, TemporaryGitCommitContext, }; +use crate::remote::workspace_io as fs; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -304,7 +305,7 @@ fn export_unstaged_with_untracked( "GIT_INDEX_FILE".into(), context.index_path.to_string_lossy().into_owned(), )]; - match std::fs::copy(index, &context.index_path) { + match fs::copy(index, &context.index_path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let output = capture_git_with_environment( @@ -498,7 +499,7 @@ fn check_patch( &["rev-parse", "--path-format=absolute", "--git-path", "index"], "Git index", )?; - match std::fs::copy(index, &context.index_path) { + match fs::copy(index, &context.index_path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(patch_io_error(error)), @@ -699,7 +700,7 @@ fn state_token( // Some Git reads refresh stat data despite optional-lock suppression. Keep // every snapshot subprocess on a copy while hashing the real index bytes. let context = TemporaryGitCommitContext::prepare_snapshot(None)?; - match std::fs::copy(&index, &context.index_path) { + match fs::copy(&index, &context.index_path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(patch_io_error(error)), @@ -799,7 +800,7 @@ fn state_token( fn ensure_working_ancestors(root: &Path, path: &Path) -> Result<(), CoreError> { let mut parent = path.parent(); while let Some(directory) = parent.filter(|directory| *directory != root) { - match std::fs::symlink_metadata(directory) { + match fs::symlink_metadata(directory) { Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { return Err(invalid( "Patch paths cannot traverse symbolic links or files", @@ -815,7 +816,7 @@ fn ensure_working_ancestors(root: &Path, path: &Path) -> Result<(), CoreError> { } fn hash_optional_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { - match std::fs::symlink_metadata(path) { + match fs::symlink_metadata(path) { Ok(_) => { hash.update([1]); hash_working_file(hash, path) @@ -830,15 +831,14 @@ fn hash_optional_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { fn hash_working_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { use std::io::Read; - let metadata = std::fs::symlink_metadata(path).map_err(patch_io_error)?; + let metadata = fs::symlink_metadata(path).map_err(patch_io_error)?; #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - hash.update(metadata.permissions().mode().to_le_bytes()); + hash.update(metadata.mode().to_le_bytes()); } hash.update([u8::from(metadata.file_type().is_symlink())]); if metadata.file_type().is_symlink() { - let target = std::fs::read_link(path).map_err(patch_io_error)?; + let target = fs::read_link(path).map_err(patch_io_error)?; add_hash_field(hash, target.as_os_str().as_encoded_bytes()); return Ok(()); } @@ -848,7 +848,7 @@ fn hash_working_file(hash: &mut Sha256, path: &Path) -> Result<(), CoreError> { )); } hash.update(metadata.len().to_le_bytes()); - let mut file = std::fs::File::open(path).map_err(patch_io_error)?; + let mut file = fs::File::open(path).map_err(patch_io_error)?; let mut buffer = [0; 65536]; loop { crate::protocol::cancellation::check()?; diff --git a/rust/lithe-core/src/git/rebase_session.rs b/rust/lithe-core/src/git/rebase_session.rs index 90ebe4562d..8c35376618 100644 --- a/rust/lithe-core/src/git/rebase_session.rs +++ b/rust/lithe-core/src/git/rebase_session.rs @@ -2,8 +2,8 @@ use super::rewrite::{GitHistoryRewriteBlocker, GitHistoryRewriteCommit, RewriteLease}; use super::*; +use crate::remote::workspace_io as fs; use std::collections::{BTreeMap, BTreeSet}; -use std::fs::{self, OpenOptions}; const SESSION_DIRECTORY: &str = "lithe-rebase-session"; const SESSION_MANIFEST: &str = "session.json"; @@ -356,14 +356,7 @@ fn ensure_owned_file(path: &Path) -> Result<(), CoreError> { fn write_file(path: &Path, bytes: &[u8]) -> Result<(), CoreError> { ensure_owned_file(path)?; - let mut file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(path) - .map_err(io_error)?; - file.write_all(bytes).map_err(io_error)?; - file.sync_all().map_err(io_error) + fs::write_synced(path, bytes).map_err(io_error) } // Status is the only persisted field that changes during a session. Reserve @@ -508,7 +501,7 @@ fn environment(root: &str, record: &SessionRecord) -> Result Result { let native = metadata_directory(root)?.join("rebase-merge"); - if !native.exists() { + if !fs::try_exists(&native).map_err(io_error)? { return Ok(false); } let read = |name: &str| { @@ -580,7 +573,7 @@ fn project_session( let status = if owned { if !operation.conflicted_paths.is_empty() { "conflict" - } else if native.join("amend").exists() + } else if fs::try_exists(native.join("amend")).map_err(io_error)? && done.last().is_some_and(|step| step.action == "edit") { "edit" diff --git a/rust/lithe-core/src/git/remote_history.rs b/rust/lithe-core/src/git/remote_history.rs new file mode 100644 index 0000000000..99c9b9793d --- /dev/null +++ b/rust/lithe-core/src/git/remote_history.rs @@ -0,0 +1,196 @@ +//! Incremental Git history on the workspace host, without local reader threads. + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use crate::remote::workspace::{self, Host}; +use lithe_remote_client::session::Event; +use lithe_remote_protocol::methods::{self, OutputStream}; +use serde_json::json; +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +const POLL: Duration = Duration::from_millis(10); +const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024; +const MAX_LINE_BYTES: usize = 1024 * 1024; +const MAX_STDERR_BYTES: usize = 64 * 1024; + +/// A cursor owns both its remote process and subscription across page requests. +pub(super) struct RemoteHistory { + host: Host, + id: u64, + events: Option>, + early: VecDeque, + line: Vec, + output: VecDeque, + stderr: Vec, + exited: bool, +} + +impl RemoteHistory { + /// Starts the same Git log argv on the selected host, never a local fallback. + pub(super) fn start(root: &str, arguments: &[String]) -> Result, CoreError> { + let Some(host) = workspace::current() else { + return Ok(None); + }; + let params = super::remote_process::parameters(&host, root, arguments, false, true, &[])?; + let id = host.connection.session.allocate_id(); + let events = host.connection.session.subscribe("process", id); + let mut stream = Self { + host, + id, + events: Some(events), + early: VecDeque::new(), + line: Vec::new(), + output: VecDeque::new(), + stderr: Vec::new(), + exited: false, + }; + let mut params = serde_json::to_value(params).expect("spawn parameters encode"); + params["processId"] = json!(id); + let pending = stream + .host + .connection + .session + .begin_request_value(methods::PROCESS_SPAWN, params, Duration::from_secs(30)) + .map_err(failure)?; + let mut bytes = 0usize; + loop { + cancellation::check()?; + if pending.poll(POLL).map_err(failure)?.is_some() { + break; + } + // Drain early output so a full subscriber cannot hide the spawn reply. + while let Ok(event) = stream.events.as_ref().expect("subscribed").try_recv() { + bytes = bytes.saturating_add(event.params.to_string().len()); + if bytes > MAX_PENDING_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote Git startup output exceeded its limit", + )); + } + stream.early.push_back(event); + } + } + Ok(Some(stream)) + } + + /// Reads one complete UTF-8 log record within the owning page's deadline. + pub(super) fn next_line(&mut self, deadline: Instant) -> Result, CoreError> { + loop { + cancellation::check()?; + if Instant::now() >= deadline { + return Err(CoreError::new( + ErrorCode::TimedOut, + "Remote Git history page timed out", + )); + } + while let Some(byte) = self.output.pop_front() { + if byte == b'\n' { + return self.take_line().map(Some); + } + self.line.push(byte); + if self.line.len() > MAX_LINE_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git history record exceeded its limit", + )); + } + } + if self.exited { + return if self.line.is_empty() { + Ok(None) + } else { + self.take_line().map(Some) + }; + } + let event = if let Some(event) = self.early.pop_front() { + event + } else { + match self.events.as_ref().expect("subscribed").recv_timeout(POLL) { + Ok(event) => event, + Err(RecvTimeoutError::Timeout) if !self.host.connection.session.is_closed() => { + continue + } + Err(_) => { + return Err(CoreError::new( + ErrorCode::ConnectionClosed, + "Remote Git history connection closed", + )) + } + } + }; + match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => { + let output: methods::ProcessOutputEvent = event.decode().map_err(failure)?; + let bytes = methods::decode_bytes(&output.data).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git history bytes") + .with_details(error.to_string()) + })?; + if output.process_id != self.id + || bytes.len() > methods::MAX_TRANSFER_BYTES as usize + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote Git history output", + )); + } + if output.stream == OutputStream::Stdout { + self.output.extend(bytes); + } else { + let remaining = MAX_STDERR_BYTES.saturating_sub(self.stderr.len()); + self.stderr + .extend_from_slice(&bytes[..bytes.len().min(remaining)]); + } + } + methods::EVENT_PROCESS_EXIT => { + let exit: methods::ProcessExitEvent = event.decode().map_err(failure)?; + if exit.process_id != self.id + || exit.exit_code != Some(0) + || exit.signal.is_some() + { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git history failed") + .with_details( + String::from_utf8_lossy(&self.stderr).trim().to_owned(), + ), + ); + } + self.exited = true; + } + _ => { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Unexpected remote Git history event", + )) + } + } + } + } + + fn take_line(&mut self) -> Result { + if self.line.last() == Some(&b'\r') { + self.line.pop(); + } + String::from_utf8(std::mem::take(&mut self.line)).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Git history is not UTF-8") + .with_details(error.to_string()) + }) + } +} + +impl Drop for RemoteHistory { + fn drop(&mut self) { + self.events.take(); + self.host.connection.session.unsubscribe("process", self.id); + let _ = self.host.connection.session.request_value( + methods::PROCESS_KILL, + json!({"processId": self.id, "force": true}), + Duration::from_secs(2), + ); + } +} + +fn failure(error: lithe_remote_client::session::ClientError) -> CoreError { + CoreError::new(ErrorCode::ProcessFailed, "Remote Git history failed") + .with_details(error.to_string()) +} diff --git a/rust/lithe-core/src/git/remote_process.rs b/rust/lithe-core/src/git/remote_process.rs new file mode 100644 index 0000000000..c8a42ee348 --- /dev/null +++ b/rust/lithe-core/src/git/remote_process.rs @@ -0,0 +1,330 @@ +//! Executes the existing Git engine's invocations on its request-scoped host. + +use super::{execution_events, execution_policy, GitProcessOutput}; +use crate::protocol::{CoreError, ErrorCode}; +use crate::remote::{workspace, workspace_host}; +use lithe_remote_protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use std::collections::BTreeMap; +use std::path::Path; +use std::time::Duration; + +const MAX_CAPTURE_BYTES: usize = 32 * 1024 * 1024; + +/// Returns `None` only for a local workspace; remote failures never run local Git. +pub(super) fn capture( + root: &str, + arguments: &[String], + input: Option<&str>, + disable_optional_locks: bool, + environment: &[(String, String)], + visible: bool, +) -> Option> { + workspace::current().map(|host| { + let session = &host.connection.session; + let params = parameters( + &host, + root, + arguments, + input.is_some(), + disable_optional_locks, + environment, + )?; + let executable = params.argv.first().cloned(); + let mut invocation = visible + .then(execution_events::Invocation::current) + .flatten(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let result = workspace_host::process_observing( + session, + params, + input.map(str::as_bytes), + &mut |event| { + match event.event.as_str() { + "process.started" => { + if let Some(invocation) = &invocation { + invocation.started_on_host(root, arguments, executable.clone()); + } + } + methods::EVENT_PROCESS_OUTPUT => { + let event: methods::ProcessOutputEvent = + event.decode().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git output") + .with_details(error.to_string()) + })?; + let bytes = methods::decode_bytes(&event.data).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote Git output bytes", + ) + .with_details(error.to_string()) + })?; + let target = if event.stream == OutputStream::Stdout { + &mut stdout + } else { + &mut stderr + }; + if target.len() + bytes.len() > MAX_CAPTURE_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git output exceeded the capture limit", + )); + } + target.extend_from_slice(&bytes); + if let Some(invocation) = &mut invocation { + invocation.output( + if event.stream == OutputStream::Stdout { + lithe_git_host::Stream::Stdout + } else { + lithe_git_host::Stream::Stderr + }, + &bytes, + ); + } + } + _ => {} + } + Ok(()) + }, + ); + let exit = result + .as_ref() + .ok() + .and_then(|value| value["exitCode"].as_i64()) + .and_then(|code| i32::try_from(code).ok()); + if let Some(invocation) = invocation { + let expected = !visible + && exit == Some(1) + && result.is_ok() + && execution_policy::command_index(arguments) + .is_some_and(|index| arguments[index] == "config"); + invocation.finished_with_expected_exit(exit, result.as_ref().err(), expected); + } + result?; + Ok(GitProcessOutput { + stdout, + stderr, + exit_code: exit.unwrap_or(1), + }) + }) +} + +/// Builds host-native argv and environment without exporting client executable paths. +pub(super) fn parameters( + host: &workspace::Host, + root: &str, + arguments: &[String], + pipe_stdin: bool, + disable_optional_locks: bool, + environment: &[(String, String)], +) -> Result { + let result = workspace_host::request_cancellable( + &host.connection.session, + methods::ENVIRONMENT_RESOLVE, + serde_json::json!({}), + Duration::from_secs(60), + ); + crate::protocol::cancellation::check()?; + let resolved: methods::EnvironmentResolveResult = + serde_json::from_value(result.map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not resolve the remote login environment", + ) + .with_details(error.to_string()) + })?) + .map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote login environment") + .with_details(error.to_string()) + })?; + let mut env = BTreeMap::new(); + if disable_optional_locks { + env.insert("GIT_OPTIONAL_LOCKS".into(), "0".into()); + } + for (key, value) in environment { + // These paths are generated by the Git engine in workspace URL identity. + let value = if matches!( + key.as_str(), + "GIT_DIR" + | "GIT_COMMON_DIR" + | "GIT_WORK_TREE" + | "GIT_INDEX_FILE" + | "LITHE_REBASE_DIR" + | "LITHE_REBASE_TODO" + | "LITHE_REBASE_MESSAGES" + ) { + host.remote_path(Path::new(value)) + .to_string_lossy() + .into_owned() + } else { + value.clone() + }; + env.insert(key.clone(), value); + } + if execution_policy::command_index(arguments).is_some_and(|index| arguments[index] != "config") + { + let inherited = resolved + .environment + .get("GIT_CONFIG_COUNT") + .map(String::as_str) + .unwrap_or("0") + .parse::() + .ok() + .filter(|count| *count <= 4096) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote Git configuration count", + ) + })?; + let values = execution_policy::temporary_config(); + for (offset, (key, value)) in values.iter().enumerate() { + env.insert( + format!("GIT_CONFIG_KEY_{}", inherited + offset), + key.clone(), + ); + env.insert( + format!("GIT_CONFIG_VALUE_{}", inherited + offset), + value.clone(), + ); + } + env.insert( + "GIT_CONFIG_COUNT".into(), + (inherited + values.len()).to_string(), + ); + } + env.insert("GIT_PAGER".into(), "cat".into()); + env.insert("LC_ALL".into(), "C".into()); + env.insert("GIT_TERMINAL_PROMPT".into(), "0".into()); + // The remote credential helper/SSH agent owns authentication until the + // host AskPass relay is installed; never export the local helper path. + let executable = remote_git_executable(host, &resolved)?; + let mut argv = vec![executable]; + argv.extend_from_slice(arguments); + Ok(SpawnParams { + process_id: 0, + argv, + cwd: Some( + host.remote_path(Path::new(root)) + .to_string_lossy() + .into_owned(), + ), + environment: EnvironmentSource::Login, + env, + pipe_stdin, + }) +} + +fn remote_git_executable( + host: &workspace::Host, + environment: &methods::EnvironmentResolveResult, +) -> Result { + if let Some((executable, _)) = host + .connection + .git_tool + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + { + return Ok(executable.clone()); + } + let path = environment + .environment + .get("PATH") + .map(String::as_str) + .unwrap_or(""); + for directory in path + .split(':') + .filter(|directory| directory.starts_with('/')) + { + let candidate = format!("{}/git", directory.trim_end_matches('/')); + let result = workspace_host::request_cancellable( + &host.connection.session, + methods::FS_STAT, + serde_json::json!({"path": candidate}), + Duration::from_secs(30), + ); + crate::protocol::cancellation::check()?; + let metadata = match result { + Ok(value) => { + serde_json::from_value::(value).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git metadata") + .with_details(error.to_string()) + })? + } + Err(lithe_remote_client::session::ClientError::Remote(error)) + if error.code == lithe_remote_protocol::ErrorCode::NotFound => + { + continue + } + Err(error) => { + return Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not resolve remote Git", + ) + .with_details(error.to_string())) + } + }; + let regular = metadata.kind == methods::FileKind::File + || metadata.symlink_target_kind == Some(methods::FileKind::File); + if regular && (metadata.kind == methods::FileKind::Symlink || metadata.mode & 0o111 != 0) { + let mut version = Vec::new(); + let result = workspace_host::process_observing( + &host.connection.session, + SpawnParams { + process_id: 0, + argv: vec![candidate.clone(), "--version".into()], + cwd: None, + environment: EnvironmentSource::Login, + env: BTreeMap::new(), + pipe_stdin: false, + }, + None, + &mut |event| { + if event.event == methods::EVENT_PROCESS_OUTPUT { + let output: methods::ProcessOutputEvent = + event.decode().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote Git version") + .with_details(error.to_string()) + })?; + let bytes = methods::decode_bytes(&output.data).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote Git version bytes", + ) + .with_details(error.to_string()) + })?; + if version.len() + bytes.len() > 4096 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote Git version output exceeded its limit", + )); + } + if output.stream == OutputStream::Stdout { + version.extend(bytes); + } + } + Ok(()) + }, + )?; + if result["exitCode"] != 0 { + return Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not inspect remote Git version", + )); + } + let version = String::from_utf8_lossy(&version).trim().to_owned(); + execution_policy::validate_version(&version)?; + *host + .connection + .git_tool + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some((candidate.clone(), version)); + return Ok(candidate); + } + } + Err(CoreError::new( + ErrorCode::ProcessStartFailed, + "Git is not available on the remote login PATH", + )) +} diff --git a/rust/lithe-core/src/git/rewrite.rs b/rust/lithe-core/src/git/rewrite.rs index 79200e9bcc..c740510779 100644 --- a/rust/lithe-core/src/git/rewrite.rs +++ b/rust/lithe-core/src/git/rewrite.rs @@ -1,9 +1,9 @@ //! Reviewed local history mutations with immutable snapshots and durable recovery refs. use super::*; +use crate::remote::workspace_io::{self as fs, File}; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap}; -use std::fs::{self, File}; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -134,7 +134,9 @@ impl RewriteLease { .map_err(|_| invalid("Unsupported Git metadata path encoding"))? .trim(), ); - let common = common.canonicalize().map_err(snapshot_io_error)?; + let common = crate::remote::workspace::identity_key( + &fs::canonicalize(&common).map_err(snapshot_io_error)?, + ); let mut active = ACTIVE_REWRITES .get_or_init(|| Mutex::new(HashMap::new())) .lock() diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 60b26ce11b..5279d65d18 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -738,6 +738,8 @@ struct RuntimeSession { provider_id: String, /// Plugin that owns the process, if any; see `StartServerRequest::plugin_id`. plugin_id: Option, + /// Fixed workspace host, inherited by protocol workers; never a global selection. + workspace_host: Option, /// Maven import settings JDT LS currently holds. Replaced as a whole when /// the workspace's Maven configuration changes; never held across another /// lock. @@ -1467,6 +1469,13 @@ impl LspEngine { fn start_server(&self, request: StartServerRequest) -> Result { validate_start_request(&request)?; + if crate::remote::workspace::is_remote() && request.provider_id.eq_ignore_ascii_case("java") + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "Remote Java language servers require a host-compatible plugin launch plan", + )); + } let startup_started_at = Instant::now(); let session_id = format!( "lsp-session-{}", @@ -1615,7 +1624,8 @@ impl LspEngine { ClientInitializeRequest { state: LspClientState::default(), root_uri: request.root_uri.clone(), - process_id: Some(std::process::id() as i64), + process_id: (!crate::remote::workspace::is_remote()) + .then_some(std::process::id() as i64), initialization_options: adapt_initialization_options( &request.provider_id, request.initialization_options, @@ -1648,6 +1658,7 @@ impl LspEngine { id: session_id.clone(), provider_id: request.provider_id, plugin_id: request.plugin_id, + workspace_host: crate::remote::workspace::current(), jdt_maven_configuration: Mutex::new(jdt_maven_configuration), maven_inputs, maven_update_order: Mutex::new(()), @@ -2871,6 +2882,7 @@ impl RuntimeSession { ) { let output_session = self.clone(); thread::spawn(move || { + let _host = crate::remote::workspace::enter(output_session.workspace_host.clone()); let mut frame_buffer = Vec::new(); let mut chunk = vec![0_u8; 8 * 1024]; while output_session.active.load(Ordering::Acquire) { @@ -2970,21 +2982,7 @@ impl RuntimeSession { CoreError::new(ErrorCode::ParseFailed, "Invalid LSP server JSON message.") .with_details(error.to_string()) })?; - let method = value.get("method").and_then(Value::as_str); - let readiness = readiness_signal(&self.provider_id, method, value.get("params")); - let java_import_progress = import_progress(&self.provider_id, method, value.get("params")); let outbound_order = self.lock_outbound_order()?; - - if value.get("method").and_then(Value::as_str) == Some("workspace/configuration") { - if let Some(response) = self.provider_configuration_response(&value)? { - return self.send_messages_or_fail( - &outbound_order, - vec![response], - "serverRequest", - ); - } - } - let response_id = if value.get("method").is_none() { lsp_value_id(value.get("id")) } else { @@ -3013,6 +3011,36 @@ impl RuntimeSession { ); return Ok(()); } + let message = match &self.workspace_host { + Some(host) => super::remote_process::map_message( + host, + &message, + false, + pending_before + .as_ref() + .map(|pending| pending.method.as_str()), + )?, + None => message, + }; + let value: Value = serde_json::from_str(&message).map_err(|error| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid mapped LSP server JSON message.", + ) + .with_details(error.to_string()) + })?; + let method = value.get("method").and_then(Value::as_str); + let readiness = readiness_signal(&self.provider_id, method, value.get("params")); + let java_import_progress = import_progress(&self.provider_id, method, value.get("params")); + if method == Some("workspace/configuration") { + if let Some(response) = self.provider_configuration_response(&value)? { + return self.send_messages_or_fail( + &outbound_order, + vec![response], + "serverRequest", + ); + } + } let reduced = { let mut state = self.lock_state()?; @@ -4831,6 +4859,10 @@ impl RuntimeSession { messages: Vec, ) -> Result<(), CoreError> { for message in messages { + let message = match &self.workspace_host { + Some(host) => super::remote_process::map_message(host, &message, true, None)?, + None => message, + }; let frame = frame_message(FrameMessageRequest { message })?.frame; self.process.write_input(frame.as_bytes())?; } @@ -4968,7 +5000,7 @@ fn java_executable_from_environment(environment: &BTreeMap) -> O /// `None` for other schemes and for files that do not exist. fn resolved_file_of_uri(uri: &str) -> Option { let path = url::Url::parse(uri).ok()?.to_file_path().ok()?; - std::fs::canonicalize(path).ok() + crate::remote::workspace_io::canonicalize(path).ok() } /// Which open documents the host reached through a symbolic link. @@ -5058,7 +5090,7 @@ fn java_entrypoint_scope_uri(root_uri: &str) -> String { url::Url::parse(root_uri) .ok() .and_then(|url| url.to_file_path().ok()) - .and_then(|path| std::fs::canonicalize(path).ok()) + .and_then(|path| crate::remote::workspace_io::canonicalize(path).ok()) .and_then(|canonical| url::Url::from_directory_path(canonical).ok()) .map(|url| url.to_string()) .unwrap_or_else(|| root_uri.to_string()) @@ -5070,7 +5102,7 @@ fn java_entrypoint_scope_uri(root_uri: &str) -> String { /// rather than in the pure normalizer that receives the result. fn canonical_workspace_root(root_uri: &str) -> Option { let path = url::Url::parse(root_uri).ok()?.to_file_path().ok()?; - let canonical = std::fs::canonicalize(path).ok()?; + let canonical = crate::remote::workspace_io::canonicalize(path).ok()?; let canonical = canonical.to_string_lossy().replace('\\', "/"); // Windows canonical paths carry a verbatim `\\?\` prefix that JDT never reports. Some( diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 7214d5a168..a31b8219e5 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -4,7 +4,8 @@ mod build_provider; mod client; mod engine; pub(crate) mod plugin_function; -mod process; +pub(crate) mod process; +pub(crate) mod remote_process; #[cfg(test)] mod scripted; mod transport; diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs index b028c52fa6..2ac05939a6 100644 --- a/rust/lithe-core/src/lsp/interface/process.rs +++ b/rust/lithe-core/src/lsp/interface/process.rs @@ -42,8 +42,9 @@ pub trait LspProcessHandle: Send + Sync { None } - /// Writes one already-framed message and flushes it. Fails once the input - /// stream is gone, which the engine turns into a transport failure. + /// Accepts one complete frame in order. Native handles flush it directly; + /// remote handles enqueue bounded delivery without blocking the UI caller. + /// A delivery failure closes the streams and becomes a transport failure. fn write_input(&self, frame: &[u8]) -> Result<(), CoreError>; /// Drops the input stream so the server observes EOF on stdin. @@ -75,6 +76,9 @@ pub struct SystemProcessLauncher; impl LspProcessLauncher for SystemProcessLauncher { fn launch(&self, spec: LspProcessSpec) -> Result { + if let Some(host) = crate::remote::workspace::current() { + return super::remote_process::launch(host, spec); + } let mut command = language_server_command(&spec); command .current_dir(&spec.working_directory) diff --git a/rust/lithe-core/src/lsp/interface/remote_process.rs b/rust/lithe-core/src/lsp/interface/remote_process.rs new file mode 100644 index 0000000000..9d0a85c27f --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/remote_process.rs @@ -0,0 +1,666 @@ +//! Bounded remote stdio for the existing plugin and language-server engine. +//! The process handle keeps its original connection; changing workspaces never +//! redirects a running backend or its cleanup to another host. + +use super::process::{LspProcessHandle, LspProcessSpec, LspProcessStreams}; +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use crate::remote::sessions::{request_error, Connection}; +use crate::remote::workspace::Host; +use lithe_remote_client::session::{ClientError, Event}; +use lithe_remote_protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use serde_json::json; +use serde_json::Value; +use std::collections::VecDeque; +use std::io::{self, Read}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::RecvTimeoutError; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +const START_TIMEOUT: Duration = Duration::from_secs(30); +const WRITE_TIMEOUT: Duration = Duration::from_secs(5); +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); +const POLL: Duration = Duration::from_millis(20); +const STREAM_BYTES: usize = 8 * 1024 * 1024; +const INPUT_BYTES: usize = 8 * 1024 * 1024; + +/// Rewrites protocol-owned URI fields, including WorkspaceEdit map keys. Text, +/// snippets, hover contents and arbitrary plugin function values stay verbatim. +pub(crate) fn map_message( + host: &Host, + message: &str, + outbound: bool, + response_method: Option<&str>, +) -> Result { + let mut value: Value = serde_json::from_str(message).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote backend JSON") + .with_details(error.to_string()) + })?; + let method = value + .get("method") + .and_then(Value::as_str) + .or(response_method) + .unwrap_or_default() + .to_owned(); + // A JSON-RPC result has no method of its own. Its request determines + // whether it is an LSP structure or opaque plugin-owned data. + if method.starts_with("lithe/") { + if outbound && method.starts_with("lithe/vcs/") { + for pointer in ["/params/root/path", "/params/tool/executable"] { + if let Some(value) = value.pointer_mut(pointer) { + if let Some(path) = value.as_str() { + *value = Value::String( + host.remote_path(std::path::Path::new(path)) + .to_string_lossy() + .into_owned(), + ); + } + } + } + } + return Ok(value.to_string()); + } + if method.is_empty() + || matches!( + method.as_str(), + "workspace/configuration" | "workspace/executeCommand" | "$/progress" + ) + || !(method == "initialize" + || method.starts_with("textDocument/") + || method.starts_with("workspace/") + || method.starts_with("window/") + || method.starts_with("callHierarchy/") + || method.starts_with("typeHierarchy/") + || method.starts_with("completionItem/") + || method.starts_with("codeAction/") + || method.starts_with("codeLens/") + || method.starts_with("documentLink/") + || method.starts_with("inlayHint/")) + { + return Ok(value.to_string()); + } + fn visit(host: &Host, value: &mut Value, outbound: bool) { + match value { + Value::Array(values) => { + for value in values { + visit(host, value, outbound); + } + } + Value::Object(object) => { + for (key, value) in object.iter_mut() { + if matches!( + key.as_str(), + "uri" | "rootUri" | "targetUri" | "oldUri" | "newUri" | "documentUri" + ) { + if let Some(mapped) = + value.as_str().and_then(|uri| host.map_uri(uri, outbound)) + { + *value = Value::String(mapped); + } + } else if key == "changes" { + if let Some(changes) = value.as_object_mut() { + let mapped = std::mem::take(changes) + .into_iter() + .map(|(uri, edits)| { + (host.map_uri(&uri, outbound).unwrap_or(uri), edits) + }) + .collect(); + *changes = mapped; + } + } + // These fields contain user/plugin data rather than LSP structures. + if !matches!( + key.as_str(), + "text" + | "newText" + | "contents" + | "documentation" + | "arguments" + | "data" + | "initializationOptions" + | "experimental" + | "settings" + | "value" + ) { + visit(host, value, outbound); + } + } + } + _ => {} + } + } + // Error data belongs to the server; it is never an LSP location result. + if let Some(payload) = value.get_mut("params") { + visit(host, payload, outbound); + } + if let Some(payload) = value.get_mut("result") { + visit(host, payload, outbound); + } + Ok(value.to_string()) +} + +/// One bounded byte stream. Readers wake on exit, disconnect and termination. +#[derive(Default)] +struct Pipe { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct PipeState { + chunks: VecDeque>, + bytes: usize, + offset: usize, + ended: bool, + failure: Option, +} + +impl Pipe { + fn push(&self, bytes: Vec) -> Result<(), CoreError> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.ended { + return Ok(()); + } + if state.bytes.saturating_add(bytes.len()) > STREAM_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend output exceeded its budget", + )); + } + if !bytes.is_empty() { + state.bytes += bytes.len(); + state.chunks.push_back(bytes); + } + self.changed.notify_all(); + Ok(()) + } + + fn end(&self, failure: Option) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.ended = true; + if state.failure.is_none() { + state.failure = failure; + } + self.changed.notify_all(); + } +} + +struct PipeReader(Arc); +impl Read for PipeReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if buffer.is_empty() { + return Ok(0); + } + let mut state = self + .0 + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + loop { + if let Some(failure) = state.failure.take() { + return Err(io::Error::other(failure)); + } + if let Some(chunk) = state.chunks.front() { + let count = buffer.len().min(chunk.len() - state.offset); + buffer[..count].copy_from_slice(&chunk[state.offset..state.offset + count]); + state.offset += count; + state.bytes -= count; + if state.offset == state.chunks.front().unwrap().len() { + state.chunks.pop_front(); + state.offset = 0; + } + return Ok(count); + } + if state.ended { + return Ok(0); + } + state = self + .0 + .changed + .wait(state) + .unwrap_or_else(|error| error.into_inner()); + } + } +} + +/// One writer owns the host stdin. Callers only enqueue complete frames; +/// a slow network or host reader cannot block document synchronization. +#[derive(Default)] +struct Input { + state: Mutex, + changed: Condvar, +} + +#[derive(Default)] +struct InputState { + frames: VecDeque>, + /// Includes the frame currently being delivered, until its acknowledgement. + bytes: usize, + closed: bool, + stopped: bool, +} + +impl Input { + fn enqueue(&self, frame: &[u8]) -> Result<(), CoreError> { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + if state.stopped || state.closed { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend stdin is closed", + )); + } + if state.bytes.saturating_add(frame.len()) > INPUT_BYTES { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend input exceeded its budget", + )); + } + state.bytes += frame.len(); + state.frames.push_back(frame.to_vec()); + self.changed.notify_one(); + Ok(()) + } + + fn close(&self) { + self.state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .closed = true; + self.changed.notify_one(); + } + + fn stop(&self) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.stopped = true; + state.frames.clear(); + state.bytes = 0; + self.changed.notify_one(); + } +} + +struct RemoteProcess { + connection: Arc, + id: u64, + stdout: Arc, + stderr: Arc, + exit: Mutex>>, + stopped: AtomicBool, + input: Arc, +} + +impl RemoteProcess { + fn finish(&self, exit: Option, failure: Option) { + let mut state = self.exit.lock().unwrap_or_else(|error| error.into_inner()); + if state.is_none() { + *state = Some(exit); + } + self.stdout.end(failure.clone()); + self.stderr.end(failure); + } + + fn cleanup(&self) { + if self.stopped.swap(true, Ordering::AcqRel) { + return; + } + self.input.stop(); + self.connection.session.unsubscribe("process", self.id); + let result = self.connection.session.request_value( + methods::PROCESS_KILL, + json!({"processId": self.id, "force": true}), + CLEANUP_TIMEOUT, + ); + if let Err(error) = result { + if !matches!(&error, ClientError::Remote(remote) if remote.code == lithe_remote_protocol::ErrorCode::UnknownResource) + && !self.connection.session.is_closed() + { + eprintln!("remote backend cleanup failed: {error}"); + } + } + self.finish(None, None); + } + + fn event(&self, event: Event) -> Result { + if event.resource() != Some(("process", self.id)) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Remote backend identity changed", + )); + } + match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => { + let output: methods::ProcessOutputEvent = + event.decode().map_err(|error| request_error("", error))?; + let bytes = methods::decode_bytes(&output.data).map_err(|_| { + CoreError::new(ErrorCode::ParseFailed, "Invalid remote backend bytes") + })?; + let pipe = if output.stream == OutputStream::Stdout { + &self.stdout + } else { + &self.stderr + }; + pipe.push(bytes)?; + Ok(false) + } + methods::EVENT_PROCESS_EXIT => { + let exit: methods::ProcessExitEvent = + event.decode().map_err(|error| request_error("", error))?; + self.finish(exit.exit_code, None); + Ok(true) + } + _ => Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid remote backend event", + )), + } + } +} + +impl Drop for RemoteProcess { + fn drop(&mut self) { + self.cleanup(); + } +} + +impl RemoteProcess { + fn deliver_input(&self, frame: &[u8]) -> Result<(), CoreError> { + let deadline = Instant::now() + WRITE_TIMEOUT; + for chunk in frame.chunks(32 * 1024) { + loop { + if self.stopped.load(Ordering::Acquire) { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend stdin is closed", + )); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(CoreError::new( + ErrorCode::TimedOut, + "Remote backend stdin timed out", + )); + } + let pending = self + .connection + .session + .begin_request_value( + methods::PROCESS_WRITE_STDIN, + json!({"processId": self.id, "data": methods::encode_bytes(chunk)}), + remaining, + ) + .map_err(|error| request_error("", error))?; + let outcome = loop { + if self.stopped.load(Ordering::Acquire) { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend stopped", + )); + } + match pending.poll(POLL) { + Ok(None) => {} + result => break result, + } + }; + match outcome { + Ok(_) => break, + Err(ClientError::Remote(error)) + if error.code == lithe_remote_protocol::ErrorCode::Busy => + { + // A full host stdin queue retries only within this frame's deadline. + std::thread::park_timeout( + POLL.min(deadline.saturating_duration_since(Instant::now())), + ); + } + Err(error) => return Err(request_error("", error)), + } + } + } + Ok(()) + } +} + +impl LspProcessHandle for RemoteProcess { + // A remote pid must never enter the local process registry. + fn write_input(&self, frame: &[u8]) -> Result<(), CoreError> { + self.input.enqueue(frame) + } + fn close_input(&self) { + self.input.close(); + } + fn exit_status(&self) -> Option> { + *self.exit.lock().unwrap_or_else(|error| error.into_inner()) + } + fn terminate(&self) { + self.cleanup(); + } +} + +pub(super) fn launch(host: Host, spec: LspProcessSpec) -> Result { + let connection = host.connection.clone(); + let id = connection.session.allocate_id(); + let events = connection.session.subscribe("process", id); + let process = Arc::new(RemoteProcess { + connection, + id, + stdout: Arc::default(), + stderr: Arc::default(), + exit: Mutex::new(None), + stopped: AtomicBool::new(false), + input: Arc::default(), + }); + let parameters = SpawnParams { + process_id: id, + argv: std::iter::once( + host.remote_path(&spec.executable) + .to_string_lossy() + .into_owned(), + ) + .chain(spec.arguments) + .collect(), + cwd: Some( + host.remote_path(&spec.working_directory) + .to_string_lossy() + .into_owned(), + ), + environment: EnvironmentSource::Login, + env: spec.environment, + pipe_stdin: true, + }; + let pending = process + .connection + .session + .begin_request_value( + methods::PROCESS_SPAWN, + serde_json::to_value(parameters).expect("backend spawn parameters encode"), + START_TIMEOUT, + ) + .map_err(|error| request_error("", error))?; + loop { + cancellation::check()?; + while let Ok(event) = events.try_recv() { + process.event(event)?; + } + if pending + .poll(POLL) + .map_err(|error| request_error("", error))? + .is_some() + { + break; + } + } + let input = process.input.clone(); + let writer = Arc::downgrade(&process); + std::thread::Builder::new() + .name("remote-plugin-stdin".into()) + .spawn(move || loop { + let frame = { + let mut state = input + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + loop { + if state.stopped { + return; + } + if let Some(frame) = state.frames.pop_front() { + break Some(frame); + } + if state.closed { + break None; + } + state = input + .changed + .wait(state) + .unwrap_or_else(|error| error.into_inner()); + } + }; + let Some(process) = writer.upgrade() else { + return; + }; + let outcome = match &frame { + Some(frame) => process.deliver_input(frame), + None => process + .connection + .session + .request_value( + methods::PROCESS_CLOSE_STDIN, + json!({"processId": process.id}), + CLEANUP_TIMEOUT, + ) + .map(|_| ()) + .map_err(|error| request_error("", error)), + }; + if let Err(error) = outcome { + process.finish(None, Some(error.message)); + process.cleanup(); + return; + } + if let Some(frame) = frame { + let mut state = input + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.bytes = state.bytes.saturating_sub(frame.len()); + } else { + return; + } + }) + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not own remote backend input", + ) + .with_details(error.to_string()) + })?; + // The worker owns no strong handle while idle. Dropping the engine handle + // closes its subscription, wakes both readers and ends the remote tree. + let weak = Arc::downgrade(&process); + std::thread::Builder::new() + .name("remote-plugin-stdio".into()) + .spawn(move || loop { + let Some(process) = weak.upgrade() else { + break; + }; + if process.stopped.load(Ordering::Acquire) { + break; + } + let result = match events.recv_timeout(POLL) { + Ok(event) => process.event(event), + Err(RecvTimeoutError::Timeout) if !process.connection.session.is_closed() => { + continue + } + Err(_) => Err(CoreError::new( + ErrorCode::ProcessFailed, + "Remote backend disconnected", + )), + }; + match result { + Ok(false) => {} + Ok(true) => { + process.cleanup(); + break; + } + Err(error) => { + process.finish(None, Some(error.message)); + process.cleanup(); + break; + } + } + }) + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not own remote backend output", + ) + .with_details(error.to_string()) + })?; + Ok(LspProcessStreams { + output: Box::new(PipeReader(process.stdout.clone())), + errors: Box::new(PipeReader(process.stderr.clone())), + handle: process, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_preserves_frames_and_counts_in_flight_bytes_until_acknowledged() { + let input = Input::default(); + input.enqueue(b"first").unwrap(); + input.enqueue(b"second").unwrap(); + input.close(); + assert!(input.enqueue(b"late").is_err()); + let mut state = input.state.lock().unwrap(); + assert_eq!(state.frames.pop_front().unwrap(), b"first"); + assert_eq!(state.frames.pop_front().unwrap(), b"second"); + assert_eq!( + state.bytes, 11, + "dequeued data remains budgeted during delivery" + ); + assert!(state.closed); + drop(state); + input.stop(); + assert_eq!(input.state.lock().unwrap().bytes, 0); + assert!(input.enqueue(b"stopped").is_err()); + } + + #[test] + fn input_overflow_rejects_a_whole_frame_without_discarding_prior_data() { + let input = Input::default(); + input.enqueue(&vec![b'x'; INPUT_BYTES]).unwrap(); + assert!(input.enqueue(b"overflow").is_err()); + let state = input.state.lock().unwrap(); + assert_eq!(state.frames.len(), 1); + assert_eq!(state.bytes, INPUT_BYTES); + } + + #[test] + fn output_partial_reads_preserve_order_and_terminal_failures_reach_readers() { + let pipe = Arc::new(Pipe::default()); + pipe.push(b"ab".to_vec()).unwrap(); + pipe.push(b"cd".to_vec()).unwrap(); + pipe.end(None); + let mut reader = PipeReader(pipe.clone()); + let mut byte = [0]; + reader.read_exact(&mut byte).unwrap(); + assert_eq!(byte, *b"a"); + let mut rest = Vec::new(); + reader.read_to_end(&mut rest).unwrap(); + assert_eq!(rest, b"bcd"); + assert_eq!(pipe.state.lock().unwrap().bytes, 0); + pipe.end(Some("connection closed".into())); + assert_eq!( + reader.read(&mut byte).unwrap_err().to_string(), + "connection closed" + ); + } + + #[test] + fn output_budget_failure_does_not_accept_excess_bytes() { + let pipe = Pipe::default(); + pipe.push(vec![0; STREAM_BYTES]).unwrap(); + assert!(pipe.push(vec![0]).is_err()); + assert_eq!(pipe.state.lock().unwrap().bytes, STREAM_BYTES); + } +} diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index c9eaed5ac5..8e3556f734 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -8,10 +8,10 @@ use crate::protocol::{ ReplacementPreviewResponse, SearchMatch, SearchResponse, WorkspaceNode, WorkspaceSnapshotResponse, }; +use crate::remote::workspace_io as fs; use regex::{Regex, RegexBuilder}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::fs; use std::path::{Path, PathBuf}; /// Default workspace exclusions, also used by Git discovery for generated directories. @@ -1086,7 +1086,7 @@ pub(crate) fn is_nested_linked_worktree(directory: &Path) -> bool { return false; }; let (Ok(main_worktree), Ok(directory)) = - (main_worktree.canonicalize(), directory.canonicalize()) + (fs::canonicalize(main_worktree), fs::canonicalize(directory)) else { return false; }; @@ -1120,7 +1120,7 @@ fn existing_root(value: &str) -> Result { "Workspace root must be a directory", )); } - path.canonicalize().map_err(CoreError::from) + fs::canonicalize(path).map_err(CoreError::from) } fn safe_relative_path(root: &Path, value: &str) -> Result { @@ -1132,7 +1132,7 @@ fn safe_relative_path(root: &Path, value: &str) -> Result { )); } let path = root.join(relative); - let canonical = path.canonicalize().map_err(CoreError::from)?; + let canonical = fs::canonicalize(path).map_err(CoreError::from)?; if !canonical.starts_with(root) { return Err(CoreError::new( ErrorCode::PermissionDenied, @@ -1153,12 +1153,12 @@ fn writable_relative_path(root: &Path, value: &str) -> Result Result) -> Option) -> Option) -> Option Result, CoreError> { - let canonical_root = root.canonicalize().map_err(CoreError::from)?; + let canonical_root = fs::canonicalize(root).map_err(CoreError::from)?; let mut candidate_directories = BTreeSet::from([PathBuf::new()]); for path in paths .iter() @@ -1186,10 +1185,10 @@ pub(crate) fn maven_root( .into_iter() .filter_map(|directory| { let candidate = root.join(&directory); - if !candidate.join("pom.xml").is_file() { + if !fs::is_file(candidate.join("pom.xml")).ok()? { return None; } - let canonical_candidate = candidate.canonicalize().ok()?; + let canonical_candidate = fs::canonicalize(&candidate).ok()?; if !canonical_candidate.starts_with(&canonical_root) { return None; } @@ -1950,15 +1949,15 @@ fn should_skip_test_source_directory(path: &Path) -> bool { } fn workspace_relative_path(root: &Path, path: &Path) -> Option { - let canonical_root = root.canonicalize().ok()?; - let canonical_path = path.canonicalize().ok()?; + let canonical_root = fs::canonicalize(root).ok()?; + let canonical_path = fs::canonicalize(path).ok()?; let relative = canonical_path.strip_prefix(canonical_root).ok()?; Some(relative.to_string_lossy().replace('\\', "/")) } /// Reads a scanned POM only when links still resolve inside its reactor. fn contained_descriptor(root: &Path, path: &Path) -> Result, CoreError> { - let (Ok(root), Ok(path)) = (root.canonicalize(), path.canonicalize()) else { + let (Ok(root), Ok(path)) = (fs::canonicalize(root), fs::canonicalize(path)) else { return Ok(None); }; if !path.starts_with(root) { @@ -2476,7 +2475,7 @@ fn existing_root(value: &str) -> Result { "Workspace root must be a directory", )); } - path.canonicalize().map_err(CoreError::from) + fs::canonicalize(path).map_err(CoreError::from) } fn normalize_relative_path(value: &str) -> Option { @@ -2511,13 +2510,12 @@ fn non_empty(value: String) -> Option { fn has_wrapper(root: &Path) -> bool { let unix = root.join("mvnw"); let windows = root.join("mvnw.cmd"); - windows.is_file() + fs::is_file(windows).unwrap_or(false) || fs::metadata(unix) .map(|metadata| { #[cfg(unix)] { - use std::os::unix::fs::PermissionsExt; - metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + metadata.is_file() && metadata.mode() & 0o111 != 0 } #[cfg(not(unix))] { diff --git a/rust/lithe-core/src/project/maven_dependency_tree.rs b/rust/lithe-core/src/project/maven_dependency_tree.rs index f20674971d..cb204b682c 100644 --- a/rust/lithe-core/src/project/maven_dependency_tree.rs +++ b/rust/lithe-core/src/project/maven_dependency_tree.rs @@ -19,8 +19,8 @@ use crate::protocol::{ CoreError, ErrorCode, MavenDependenciesResponse, MavenDependencyResolutionResponse, MavenDependencyResponse, }; +use crate::remote::workspace_io::File; use serde::Deserialize; -use std::fs::File; use std::io::{BufRead, BufReader, ErrorKind, Read}; use std::path::Path; diff --git a/rust/lithe-core/src/project/maven_test_reports.rs b/rust/lithe-core/src/project/maven_test_reports.rs index ffc1a59aca..5835844503 100644 --- a/rust/lithe-core/src/project/maven_test_reports.rs +++ b/rust/lithe-core/src/project/maven_test_reports.rs @@ -13,11 +13,11 @@ //! Note: the ownership decision is recorded in .agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md use crate::protocol::{CoreError, ErrorCode, MavenTestCaseResponse}; +use crate::remote::workspace_io as fs; use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; use serde::Deserialize; use std::collections::BTreeMap; -use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, UNIX_EPOCH}; @@ -405,17 +405,15 @@ pub(crate) fn module_root( _ => Ok(workspace_root.to_path_buf()), }; }; - let candidate = workspace_root - .join(module.replace('\\', "/")) - .canonicalize() - .map_err(|_| { + let candidate = + fs::canonicalize(workspace_root.join(module.replace('\\', "/"))).map_err(|_| { CoreError::new( ErrorCode::InvalidRequest, "Maven test module does not exist", ) .with_details(module.to_string()) })?; - if !candidate.starts_with(workspace_root) || !candidate.is_dir() { + if !candidate.starts_with(workspace_root) || !fs::is_dir(&candidate).map_err(CoreError::from)? { return Err(CoreError::new( ErrorCode::InvalidRequest, "Maven test module must be inside the workspace", @@ -428,10 +426,8 @@ pub(crate) fn module_root( /// The closest directory at or above `source_path`'s parent that holds a /// `pom.xml`, never leaving the workspace; the workspace root otherwise. fn nearest_module(workspace_root: &Path, source_path: &str) -> Result { - let source = workspace_root - .join(source_path.replace('\\', "/")) - .canonicalize() - .map_err(|_| { + let source = + fs::canonicalize(workspace_root.join(source_path.replace('\\', "/"))).map_err(|_| { CoreError::new( ErrorCode::InvalidRequest, "Maven test source does not exist", @@ -448,7 +444,7 @@ fn nearest_module(workspace_root: &Path, source_path: &str) -> Result, hidden_file_patterns: Vec, names_only: bool, @@ -77,6 +78,7 @@ fn cache() -> &'static std::sync::Mutex> { fn key(root: &Path, rules: &VisibilityRules) -> IndexKey { IndexKey { root: root.to_path_buf(), + host: workspace::identity_key(root), hidden_directories: rules.hidden_directories.clone(), hidden_file_patterns: rules.hidden_file_patterns.clone(), names_only: false, @@ -98,8 +100,9 @@ pub(crate) fn get_or_build(root: &Path, rules: &VisibilityRules) -> Result Result>() }; @@ -178,7 +182,7 @@ pub(crate) fn update_paths( indexes .iter() // Search-only exclusions create rule variants; keep all warm variants current. - .filter(|(cache_key, _)| cache_key.root == root) + .filter(|(cache_key, _)| cache_key.host == workspace::identity_key(root)) .map(|(key, index)| (key.clone(), Arc::clone(index))) .collect::>() }; @@ -391,18 +395,18 @@ fn normalized_update_path(root: &Path, value: &str) -> Option { } pub(crate) fn canonicalize_with_missing_components(path: &Path) -> Option { - if let Ok(canonical) = path.canonicalize() { + if let Ok(canonical) = fs::canonicalize(path) { return Some(canonical); } // Deleted paths cannot be canonicalized directly. Canonicalize their // nearest surviving ancestor, then append the missing path components. let mut ancestor = path; let mut suffix = Vec::::new(); - while !ancestor.exists() { + while !fs::try_exists(ancestor).ok()? { suffix.push(ancestor.file_name()?.to_os_string()); ancestor = ancestor.parent()?; } - let mut normalized = ancestor.canonicalize().ok()?; + let mut normalized = fs::canonicalize(ancestor).ok()?; for component in suffix.iter().rev() { normalized.push(component); } @@ -507,6 +511,7 @@ pub(crate) fn make_symbol_match(indexed: &IndexedSymbol, path: String) -> Search #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn source_caps_build_only_required_cold_indexes_and_skip_generated_text_enumeration() { diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index de41103e2b..119709391e 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -19,6 +19,9 @@ pub struct CoreRequest { /// Host-owned Git preferences, scoped to this request only. #[serde(default)] pub git_execution: Option, + /// Remote host selected by the native workspace registry; absent for local workspaces. + #[serde(default)] + pub(crate) remote_workspace: Option, /// Stable compatibility name resolved by [`CoreCommand::parse`]. pub command: String, /// Command-specific JSON object; omitted payloads deserialize as JSON null. @@ -402,6 +405,20 @@ pub enum CoreCommand { RemoteReadFile, /// Saves a file over an open remote connection (`remote.writeFile`). RemoteWriteFile, + /// Performs a non-streaming operation on an existing workspace host. + RemoteHostRequest, + /// Owns a remote process and relays output until it exits or is cancelled. + RemoteRunProcess, + /// Captures a short remote process with explicit time and output budgets. + RemoteCaptureProcess, + /// Owns a remote PTY and relays output until it exits or is cancelled. + RemoteRunTerminal, + /// Owns a host watch and relays changes until cancellation or root removal. + RemoteRunWatch, + /// Streams a native source into integrity-checked host file staging. + RemoteUploadFile, + /// Returns cached handshake capabilities of one connection. + RemoteHostInfo, /// Inspects repository initialization and scoped commit identity. GitRepositorySetup, /// Initializes an existing folder outside any repository. @@ -670,6 +687,13 @@ impl CoreCommand { "remote.closeAll" => Some(Self::RemoteCloseAll), "remote.readFile" => Some(Self::RemoteReadFile), "remote.writeFile" => Some(Self::RemoteWriteFile), + "remote.hostRequest" => Some(Self::RemoteHostRequest), + "remote.runProcess" => Some(Self::RemoteRunProcess), + "remote.captureProcess" => Some(Self::RemoteCaptureProcess), + "remote.runTerminal" => Some(Self::RemoteRunTerminal), + "remote.runWatch" => Some(Self::RemoteRunWatch), + "remote.uploadFile" => Some(Self::RemoteUploadFile), + "remote.hostInfo" => Some(Self::RemoteHostInfo), "git.historyRewritePreview" => Some(Self::GitHistoryRewritePreview), "git.rebasePreview" => Some(Self::GitRebasePreview), "git.repositorySetup" => Some(Self::GitRepositorySetup), diff --git a/rust/lithe-core/src/remote/mod.rs b/rust/lithe-core/src/remote/mod.rs index 4f48bc6f69..c2c4e885ab 100644 --- a/rust/lithe-core/src/remote/mod.rs +++ b/rust/lithe-core/src/remote/mod.rs @@ -18,6 +18,10 @@ use std::process::{Command, ExitStatus}; use std::time::{Duration, Instant}; pub(crate) mod sessions; +pub(crate) mod upload; +pub(crate) mod workspace; +pub(crate) mod workspace_host; +pub(crate) mod workspace_io; /// Seconds OpenSSH waits for the TCP connection when the profile sets none. const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; diff --git a/rust/lithe-core/src/remote/sessions.rs b/rust/lithe-core/src/remote/sessions.rs index 5c8e627592..694a60309b 100644 --- a/rust/lithe-core/src/remote/sessions.rs +++ b/rust/lithe-core/src/remote/sessions.rs @@ -87,12 +87,26 @@ pub(crate) struct ConnectResult { diagnostics: String, } -struct Connection { - session: RemoteSession, - child: Mutex, - tree: ProcessTree, +pub(crate) struct Connection { + pub(crate) session: RemoteSession, + pub(crate) git_tool: Mutex>, + transport: ConnectionTransport, } +/// Owns the native transport independently of the framed workspace session. +enum ConnectionTransport { + Ssh { + child: Mutex, + tree: ProcessTree, + }, + #[cfg(test)] + InProcess, +} + +#[cfg(all(test, unix))] +#[path = "sessions_integration_tests.rs"] +mod integration_tests; + impl Connection { fn close(&self) { let _ = self.session.request_value( @@ -100,13 +114,18 @@ impl Connection { serde_json::Value::Null, EXIT_GRACE, ); - let _ = self.tree.terminate(EXIT_GRACE); - let mut child = self - .child - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let _ = child.kill(); - let _ = child.wait(); + match &self.transport { + ConnectionTransport::Ssh { child, tree } => { + let _ = tree.terminate(EXIT_GRACE); + let mut child = child + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = child.kill(); + let _ = child.wait(); + } + #[cfg(test)] + ConnectionTransport::InProcess => {} + } } } @@ -138,7 +157,7 @@ fn registry() -> &'static Mutex>> { CONNECTIONS.get_or_init(Mutex::default) } -fn lookup(id: &str) -> Result, CoreError> { +pub(super) fn lookup(id: &str) -> Result, CoreError> { registry() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -434,8 +453,11 @@ pub(super) fn connect_with( id.clone(), Arc::new(Connection { session, - child: Mutex::new(child), - tree, + git_tool: Mutex::new(None), + transport: ConnectionTransport::Ssh { + child: Mutex::new(child), + tree, + }, }), ); Ok(ConnectResult { @@ -624,7 +646,13 @@ pub(crate) fn close_all() -> usize { /// Closes the registered connections in parallel. pub(super) fn close_registered() -> usize { - let connections: Vec> = registry() + close_registry(registry()) +} + +/// Drains the owned registry, allowing isolated lifecycle tests without closing +/// unrelated sessions concurrently exercising the application registry. +pub(super) fn close_registry(connections: &Mutex>>) -> usize { + let connections: Vec> = connections .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .drain() @@ -641,7 +669,8 @@ pub(super) fn close_registered() -> usize { count } -fn request_error(session_id: &str, error: ClientError) -> CoreError { +/// Translates host transport failures without losing their stable remote category. +pub(crate) fn request_error(session_id: &str, error: ClientError) -> CoreError { match error { ClientError::Remote(error) => { let code = match error.code { @@ -650,6 +679,7 @@ fn request_error(session_id: &str, error: ClientError) -> CoreError { _ => ErrorCode::ProcessFailed, }; CoreError::new(code, error.message) + .with_details(serde_json::json!({"remoteCode": error.code}).to_string()) } ClientError::Timeout => CoreError::new( ErrorCode::TimedOut, diff --git a/rust/lithe-core/src/remote/sessions_integration_tests.rs b/rust/lithe-core/src/remote/sessions_integration_tests.rs new file mode 100644 index 0000000000..e768f1a6da --- /dev/null +++ b/rust/lithe-core/src/remote/sessions_integration_tests.rs @@ -0,0 +1,816 @@ +//! Real framed-host integration coverage for remote workspace semantic engines. + +use super::*; +use serde_json::{json, Value}; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; + +const STEP: Duration = Duration::from_secs(10); + +/// Owns socket deadlines, registered identity, server completion and scratch data. +struct HostFixture { + id: String, + root: std::path::PathBuf, + socket: UnixStream, + done: mpsc::Receiver<()>, + worker: Option>, +} + +impl HostFixture { + fn new() -> Self { + let root = crate::tests::support::temporary_root("remote-git-integration"); + std::fs::create_dir_all(root.join("host")).unwrap(); + std::fs::create_dir_all(root.join("placeholder")).unwrap(); + let root = root.canonicalize().unwrap(); + let (client, server) = UnixStream::pair().unwrap(); + server.set_read_timeout(Some(STEP)).unwrap(); + server.set_write_timeout(Some(STEP)).unwrap(); + let socket = client.try_clone().unwrap(); + let (finished, done) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = lithe_remote_server::serve_stdio(server.try_clone().unwrap(), server); + let _ = finished.send(()); + result.unwrap(); + }); + let (session, events) = RemoteSession::from_streams( + client.try_clone().unwrap(), + client, + &ConnectOptions { + handshake_timeout: STEP, + ..ConnectOptions::default() + }, + ) + .unwrap(); + drop(events); + let id = nonce(); + registry().lock().unwrap().insert( + id.clone(), + Arc::new(Connection { + session, + git_tool: Mutex::new(None), + transport: ConnectionTransport::InProcess, + }), + ); + Self { + id, + root, + socket, + done, + worker: Some(worker), + } + } + + fn call(&self, command: &str, mut payload: Value) -> Value { + payload["root"] = json!(self.root.join("placeholder")); + self.request(command, payload) + } + + fn request(&self, command: &str, payload: Value) -> Value { + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "id": nonce(), "command": command, "timeoutMilliseconds": 30_000, + "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": self.root.join("host")}, + "payload": payload, + }).to_string())).unwrap(); + assert_eq!(response["ok"], true, "{command}: {response}"); + assert!( + response["data"]["operationError"].is_null(), + "{command}: {response}" + ); + response["data"].clone() + } + + fn git(&self, args: &[&str]) -> Value { + let data = self.call("git.command", json!({"arguments": args})); + assert_eq!(data["exitCode"], 0, "{args:?}: {data}"); + data + } +} + +#[test] +fn remote_build_detects_host_models_normalizes_diagnostics_and_invalidates_sync() { + let host = HostFixture::new(); + let root = host.root.join("host"); + let placeholder = host.root.join("placeholder"); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"host-only\"\nversion = \"1.0.0\"\n[features]\nfast = []\n", + ) + .unwrap(); + let manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json" + ))) + .unwrap(); + let detection = host.request( + "build.detect", + json!({"workspaceRoot": placeholder, + "plugins": [{"manifest": manifest}], "options": {}}), + ); + assert_eq!(detection["status"], "ok", "{detection}"); + let instance = &detection["detection"]["instances"][0]; + assert_eq!(instance["id"], "cargo:.", "{detection}"); + assert_eq!( + instance["model"]["projects"][0]["name"], "host-only", + "{detection}" + ); + let call = |command, request| { + host.request( + command, + json!({"manifest": manifest, + "buildSystemID": "cargo", "workspaceRoot": placeholder, "request": request}), + ) + }; + let diagnostics = call( + "build.decodeDiagnostics", + json!({"workspaceRoot": placeholder, "root": ".", + "output": format!("{}/src/main.rs:2:3: error: remote diagnostic", root.display())}), + ); + assert_eq!( + diagnostics["diagnostics"][0]["path"], "src/main.rs", + "{diagnostics}" + ); + let sync = || { + call( + "build.syncFingerprint", + json!({"workspaceRoot": placeholder, "root": ".", "projects": ["."], "options": {}}), + ) + }; + let before = sync(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"changed\"\nversion = \"1.0.0\"\n", + ) + .unwrap(); + let after = sync(); + assert_ne!(before, after, "{before}"); + // Reported symlinks must not expose host paths outside this workspace. + std::fs::write(host.root.join("outside.rs"), "private").unwrap(); + std::os::unix::fs::symlink(host.root.join("outside.rs"), root.join("src/link.rs")).unwrap(); + let escaped = call( + "build.decodeDiagnostics", + json!({"workspaceRoot": placeholder, "root": ".", + "output": format!("{}/src/link.rs:1:1: error: escaped", root.display())}), + ); + assert_eq!(escaped["diagnostics"], json!([]), "{escaped}"); + assert_eq!(std::fs::read_dir(placeholder).unwrap().count(), 0); +} + +#[test] +fn remote_maven_build_reads_reactor_and_current_host_test_reports() { + let host = HostFixture::new(); + let root = host.root.join("host"); + let placeholder = host.root.join("placeholder"); + std::fs::create_dir_all(root.join("child/target/surefire-reports")).unwrap(); + std::fs::write(root.join("pom.xml"), "4.0.0fixtureparent1pomchild").unwrap(); + std::fs::write(root.join("child/pom.xml"), "4.0.0fixtureparent1child").unwrap(); + std::fs::write(root.join("child/target/surefire-reports/TEST-Fixture.xml"), "").unwrap(); + let manifest: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" + ))) + .unwrap(); + let detected = host.request( + "build.detect", + json!({"workspaceRoot": placeholder, + "plugins": [{"manifest": manifest}], "options": {}}), + ); + assert_eq!(detected["status"], "ok", "{detected}"); + let projects = detected["detection"]["instances"][0]["model"]["projects"] + .as_array() + .unwrap(); + assert!( + projects.iter().any(|project| project["path"] == "child"), + "{detected}" + ); + let call = |not_before| { + host.request("build.decodeTestResults", json!({"manifest": manifest, + "buildSystemID": "maven", "workspaceRoot": placeholder, "request": {"workspaceRoot": placeholder, + "root": ".", "projects": [".", "child"], "output": "", "notBeforeMillis": not_before}})) + }; + let current = call(0); + assert_eq!(current["status"], "ok", "{current}"); + assert!(current.to_string().contains("hostTest"), "{current}"); + let stale = call(u64::MAX / 2); + assert!(!stale.to_string().contains("hostTest"), "{stale}"); +} + +impl Drop for HostFixture { + fn drop(&mut self) { + let _ = crate::execute_json(&json!({ + "id": nonce(), "command": "workspace.searchIndex.invalidate", "timeoutMilliseconds": 1_000, + "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": self.root.join("host")}, + "payload": {"root": self.root.join("placeholder")} + }).to_string()); + if let Some(connection) = registry().lock().unwrap().remove(&self.id) { + connection.close(); + } + let _ = self.socket.shutdown(Shutdown::Both); + if self.done.recv_timeout(STEP + STEP).is_ok() { + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } else { + if !std::thread::panicking() { + panic!("remote test host did not finish within its cleanup deadline"); + } + } + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[test] +fn remote_search_reads_host_content_and_applies_incremental_updates_without_local_cache_leaks() { + let host = HostFixture::new(); + std::fs::create_dir(host.root.join("host/src")).unwrap(); + std::fs::write( + host.root.join("host/src/RemoteName.java"), + "class RemoteName { void remoteMethod() {} }\n", + ) + .unwrap(); + // Warm the same URL locally first: host identity must separate its empty index. + let local: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": nonce(), "command": "workspace.searchEverywhere", + "payload": {"root": host.root.join("placeholder"), "query": "RemoteName"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(local["ok"], true, "{local}"); + assert_eq!(local["data"]["matches"], json!([])); + let found = host.call("workspace.searchEverywhere", json!({"query": "RemoteName"})); + for kind in ["file", "type", "content"] { + assert!( + found["matches"] + .as_array() + .unwrap() + .iter() + .any(|item| item["kind"] == kind && item["path"] == "src/RemoteName.java"), + "{kind}: {found}" + ); + } + std::fs::write( + host.root.join("host/src/RemoteName.java"), + "class ChangedName {}\n", + ) + .unwrap(); + host.call( + "workspace.searchIndex.update", + json!({"paths": ["src/RemoteName.java"]}), + ); + let changed = host.call("workspace.search", json!({"query": "ChangedName"})); + assert!( + changed["matches"] + .as_array() + .unwrap() + .iter() + .any(|item| item["path"] == "src/RemoteName.java"), + "{changed}" + ); + let missing = host.call("workspace.search", json!({"query": "remoteMethod"})); + assert_eq!(missing["matches"], json!([])); + let read = host.call("file.read", json!({"path": "src/RemoteName.java"})); + assert_eq!(read["text"], "class ChangedName {}\n"); + host.call( + "file.write", + json!({"path": "written.txt", "text": "remote write\n"}), + ); + assert_eq!( + std::fs::read(host.root.join("host/written.txt")).unwrap(), + b"remote write\n" + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0 + ); + let local: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": nonce(), "command": "workspace.searchIndex.invalidate", + "payload": {"root": host.root.join("placeholder")} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(local["ok"], true, "{local}"); +} + +#[test] +fn real_remote_git_status_stage_commit_and_history_never_touch_the_placeholder() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "user.name", "Lithe Remote Test"]); + host.git(&["config", "user.email", "remote@example.invalid"]); + std::fs::write(host.root.join("host/remote.txt"), b"host-only\n").unwrap(); + let status = host.call("git.status", json!({})); + assert_eq!(status["repositoryRoot"], "."); + assert_eq!(status["changes"][0]["path"], "remote.txt"); + let staged = host.call( + "git.write", + json!({"operation": "stage", "paths": ["remote.txt"]}), + ); + assert_eq!(staged["exitCode"], 0, "{staged}"); + host.git(&["commit", "-qm", "first remote commit"]); + std::fs::write(host.root.join("host/remote.txt"), b"changed remotely\n").unwrap(); + host.git(&["add", "remote.txt"]); + host.git(&["commit", "-qm", "second remote commit"]); + let first = host.call("git.historyPage", json!({"limit": 1})); + assert_eq!(first["commits"][0]["subject"], "second remote commit"); + let cursor = first["nextCursor"].as_str().expect("a second page"); + let second = host.call("git.historyPage", json!({"limit": 1, "cursor": cursor})); + assert_eq!(second["commits"][0]["subject"], "first remote commit"); + if let Some(cursor) = second["nextCursor"].as_str() { + host.call("git.historyCursorClose", json!({"cursor": cursor})); + } + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0 + ); + assert!(host.root.join("host/.git/index").is_file()); +} + +#[test] +fn remote_git_snapshot_selected_commit_and_unsaved_line_history_preserve_the_real_index() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "user.name", "Lithe Remote Test"]); + host.git(&["config", "user.email", "remote@example.invalid"]); + std::fs::write(host.root.join("host/selected.txt"), b"one\nbase\n").unwrap(); + std::fs::write(host.root.join("host/unrelated.txt"), b"base\n").unwrap(); + host.git(&["add", "--all"]); + host.git(&["commit", "-qm", "initial remote content"]); + std::fs::write(host.root.join("host/selected.txt"), b"staged\nbase\n").unwrap(); + std::fs::write(host.root.join("host/unrelated.txt"), b"keep staged\n").unwrap(); + host.git(&["add", "--all"]); + std::fs::write(host.root.join("host/selected.txt"), b"staged\nworktree\n").unwrap(); + let index = std::fs::read(host.root.join("host/.git/index")).unwrap(); + + let snapshot = host.call( + "git.diff", + json!({"pathspecs": ["selected.txt"], "worktreeSnapshot": true}), + ); + let patch = snapshot["patch"].as_str().unwrap(); + assert!( + patch.contains("+staged") && patch.contains("+worktree"), + "{snapshot}" + ); + assert_eq!( + std::fs::read(host.root.join("host/.git/index")).unwrap(), + index + ); + let committed = host.call( + "git.write", + json!({"operation": "commit", "paths": ["selected.txt"], "message": "selected remote content"}), + ); + assert_eq!(committed["exitCode"], 0, "{committed}"); + assert_eq!( + host.git(&["show", "HEAD:selected.txt"])["stdout"], + "staged\nworktree\n" + ); + assert_eq!( + host.git(&["show", "HEAD:unrelated.txt"])["stdout"], + "base\n" + ); + assert_eq!( + host.git(&["diff", "--cached", "--name-only"])["stdout"], + "unrelated.txt\n" + ); + + let history = host.call( + "git.lineHistory", + json!({"path": "selected.txt", "content": "unsaved\nstaged\nworktree\n", "startLine": 3, "endLine": 3}), + ); + assert_eq!(history["commits"][0]["subject"], "selected remote content"); + assert_eq!(history["unmappedLines"], json!([])); + assert_eq!( + std::fs::read(host.root.join("host/selected.txt")).unwrap(), + b"staged\nworktree\n" + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0, + "index and source snapshots must stay on the workspace host" + ); + assert!(std::fs::read_dir(host.root.join("host/.git")) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("lithe-commit-"))); +} + +#[test] +fn remote_git_worktree_and_clone_destinations_use_the_host_filesystem() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "user.name", "Lithe Remote Test"]); + host.git(&["config", "user.email", "remote@example.invalid"]); + std::fs::write(host.root.join("host/tracked.txt"), b"host-only\n").unwrap(); + host.git(&["add", "tracked.txt"]); + host.git(&["commit", "-qm", "remote worktree source"]); + + let destination = host.root.join("placeholder/.worktrees/topic with spaces"); + let created = host.call( + "git.write", + json!({"operation": "createWorktree", "destination": destination, + "worktreeMode": "detached", "gitReference": { + "kind": "local", "fullName": "refs/heads/main", "shortName": "main" + }}), + ); + assert_eq!(created["exitCode"], 0, "{created}"); + assert!(host + .root + .join("host/.worktrees/topic with spaces/.git") + .is_file()); + let entries = host.call("git.worktrees", json!({})); + let entry = entries["worktrees"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["path"] == json!(destination)) + .expect("worktree paths must retain the client's workspace identity"); + assert_eq!(entry["isCurrent"], false); + assert_eq!(entry["isPrimary"], false); + for operation in ["lockWorktree", "unlockWorktree", "removeWorktree"] { + let result = host.call( + "git.write", + json!({"operation": operation, "destination": entry["path"]}), + ); + assert_eq!(result["exitCode"], 0, "{operation}: {result}"); + } + assert!(!host.root.join("host/.worktrees/topic with spaces").exists()); + + let cloned = host.call( + "git.write", + json!({"operation": "clone", "remote": host.root.join("host"), + "destination": host.root.join("placeholder/cloned project")}), + ); + assert_eq!(cloned["exitCode"], 0, "{cloned}"); + assert_eq!( + std::fs::read(host.root.join("host/cloned project/tracked.txt")).unwrap(), + b"host-only\n" + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0, + "remote Git must never create worktrees or clones in the placeholder" + ); +} + +#[test] +fn remote_git_configuration_reports_the_host_executable_and_sources() { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + host.git(&["config", "fetch.prune", "true"]); + let request = |command| -> Value { + serde_json::from_str(&crate::execute_json(&json!({ + "id": nonce(), "command": command, "timeoutMilliseconds": 10_000, + "remoteWorkspace": {"sessionId": host.id, "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")}, + // This client override must not be used on the workspace host. + "gitExecution": {"executable": "/fixture/client-only/git"}, + "payload": {"root": host.root.join("placeholder")} + }).to_string())).unwrap() + }; + let located = request("git.executable"); + assert_eq!(located["ok"], true, "{located}"); + let executable = located["data"]["executable"].as_str().unwrap(); + assert!(executable.starts_with('/'), "{located}"); + assert_ne!(executable, "/fixture/client-only/git"); + let inspected = request("git.executionInspect"); + assert_eq!(inspected["ok"], true, "{inspected}"); + assert_eq!(inspected["data"]["executable"], executable); + assert_eq!(inspected["data"]["version"], located["data"]["version"]); + let entry = inspected["data"]["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["key"] == "fetch.prune" && entry["scope"] == "local") + .unwrap(); + assert_eq!(entry["value"], "true"); + assert!(entry["origin"].as_str().unwrap().contains(".git/config")); + // Disconnect after a cached resolution: errors must still propagate instead + // of showing an empty setting or probing the client's selected executable. + lookup(&host.id).unwrap().close(); + let closed = request("git.executable"); + assert_eq!(closed["ok"], false, "{closed}"); +} + +#[test] +fn remote_workspace_file_limits_preserve_host_content_and_report_disconnects() { + use crate::remote::{workspace, workspace_io as fs}; + use std::io::Read; + + let host = HostFixture::new(); + let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(10_000)); + let workspace = serde_json::from_value(json!({"sessionId": host.id, + "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) + .unwrap(); + let _workspace = workspace::Scope::begin(Some(workspace), "git.status").unwrap(); + let local = host.root.join("placeholder"); + let remote = host.root.join("host"); + let content = vec![b'x'; 2 * 1024 * 1024 + 7]; + fs::write(local.join("file"), &content).unwrap(); + assert_eq!(fs::read(local.join("file")).unwrap(), content); + + let oversized = vec![b'y'; methods::MAX_TRANSFER_BYTES as usize + 1]; + assert_eq!( + fs::write(local.join("file"), &oversized) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!(std::fs::read(remote.join("file")).unwrap(), content); + + // A sparse file checks the total materialization budget without a large + // allocation, while streaming consumers can still inspect its contents. + let sparse = std::fs::File::create(remote.join("large")).unwrap(); + sparse.set_len(32 * 1024 * 1024 + 1).unwrap(); + assert_eq!( + fs::read(local.join("large")).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + let mut prefix = [1; 32]; + fs::File::open(local.join("large")) + .unwrap() + .read_exact(&mut prefix) + .unwrap(); + assert_eq!(prefix, [0; 32]); + + assert!(!fs::try_exists(local.join("absent")).unwrap()); + assert!(fs::is_dir(&local).unwrap()); + lookup(&host.id).unwrap().close(); + assert!(fs::try_exists(local.join("file")).is_err()); + assert!(fs::is_dir(&local).is_err()); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0); +} + +#[test] +fn captured_host_process_preserves_binary_streams_and_cleans_up_limit_failures() { + let host = HostFixture::new(); + let capture = |argv: &[&str], timeout: u64, limit: usize| { + let _scope = crate::protocol::cancellation::Scope::begin(None, Some(5_000)); + crate::remote::workspace_host::capture_process( + serde_json::from_value(json!({ + "sessionId": host.id, "params": {"processId": 0, "argv": argv}, + "timeoutMilliseconds": timeout, "maxOutputBytes": limit + })) + .unwrap(), + ) + }; + let output = capture( + &[ + "/bin/sh", + "-c", + r"printf 'a\000b'; printf 'err' >&2; exit 7", + ], + 2_000, + 6, + ) + .unwrap(); + assert_eq!( + lithe_remote_protocol::methods::decode_bytes(output["stdout"].as_str().unwrap()).unwrap(), + b"a\0b" + ); + assert_eq!( + lithe_remote_protocol::methods::decode_bytes(output["stderr"].as_str().unwrap()).unwrap(), + b"err" + ); + assert_eq!(output["exitCode"], 7); + assert!(matches!( + capture(&["/bin/sh", "-c", "printf abcd; printf efgh >&2"], 2_000, 7) + .unwrap_err() + .code, + ErrorCode::ProcessFailed + )); + // The long-lived child has no natural completion within the test deadline; + // only the capture owner can terminate it when its budget expires. + assert!(matches!( + capture(&["/bin/sleep", "60"], 50, 128).unwrap_err().code, + ErrorCode::TimedOut + )); + let connection = lookup(&host.id).unwrap(); + let deadline = Instant::now() + STEP; + loop { + let state = connection + .session + .request_value(methods::SERVER_STATE, json!({}), STEP) + .unwrap(); + if state["processes"].as_array().unwrap().is_empty() { + break; + } + assert!( + Instant::now() < deadline, + "capture leaked a host process: {state}" + ); + std::thread::yield_now(); + } +} + +#[test] +fn native_plugin_upload_streams_beyond_one_frame_and_rejects_mismatch_without_publication() { + use sha2::{Digest, Sha256}; + use std::os::unix::fs::PermissionsExt; + let host = HostFixture::new(); + let source = host.root.join("native-package"); + let target = host.root.join("host/package"); + let content = vec![b'p'; methods::MAX_TRANSFER_BYTES as usize + 7]; + std::fs::write(&source, &content).unwrap(); + let digest = format!("{:x}", Sha256::digest(&content)); + let upload = |path: &std::path::Path, expected: &str| { + let request = serde_json::from_value(json!({"sessionId": host.id, + "sourcePath": source, "path": path, "sha256": expected, "mode": 0o500})) + .unwrap(); + super::super::upload::upload(request) + }; + let receipt = upload(&target, &digest).unwrap(); + assert_eq!(receipt["sha256"], digest); + assert_eq!(receipt["size"], content.len()); + assert_eq!(std::fs::read(&target).unwrap(), content); + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o500 + ); + assert!( + upload(&target, &digest).is_err(), + "upload must not replace installed bytes" + ); + let bad = host.root.join("host/bad"); + assert!(upload(&bad, &"0".repeat(64)).is_err()); + assert!(!bad.exists()); + assert_eq!( + std::fs::read_dir(host.root.join("host")).unwrap().count(), + 1 + ); + assert_eq!( + std::fs::read_dir(host.root.join("placeholder")) + .unwrap() + .count(), + 0 + ); +} + +#[test] +fn plugin_stdio_uses_its_owned_host_and_reclaims_the_remote_process() { + use crate::lsp::interface::process::{ + LspProcessLauncher, LspProcessSpec, SystemProcessLauncher, + }; + use std::io::Read; + let host = HostFixture::new(); + let workspace = serde_json::from_value(json!({"sessionId": host.id, + "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) + .unwrap(); + let scope = super::super::workspace::Scope::begin(Some(workspace), "lsp.startServer").unwrap(); + let streams = SystemProcessLauncher + .launch(LspProcessSpec { + executable: "/bin/sh".into(), + arguments: vec!["-c".into(), "pwd >&2; cat".into()], + working_directory: host.root.join("placeholder"), + environment: Default::default(), + }) + .unwrap(); + // Later calls execute without a current workspace. The handle must retain + // its original connection instead of choosing a local or newly selected host. + drop(scope); + let input = b"Content-Length: 2\r\n\r\n{}"; + streams.handle.write_input(input).unwrap(); + streams.handle.close_input(); + let (done, completed) = mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut output = streams.output; + let mut errors = streams.errors; + let result = output + .read_to_end(&mut stdout) + .and_then(|_| errors.read_to_end(&mut stderr)); + let _ = done.send((result, stdout, stderr)); + }); + let result = completed.recv_timeout(STEP); + // Termination wakes readers on assertion failures and has its own deadline. + streams.handle.terminate(); + let outcome = match result { + Ok(result) => result, + Err(error) => { + if completed.recv_timeout(STEP).is_ok() { + reader.join().unwrap(); + } + panic!("remote stdio did not complete: {error}") + } + }; + reader.join().unwrap(); + let (io_result, stdout, stderr) = outcome; + io_result.expect("remote stdio must reach EOF without a transport error"); + assert_eq!(stdout, input); + assert_eq!( + String::from_utf8(stderr).unwrap().trim(), + host.root.join("host").to_str().unwrap() + ); + assert_eq!( + streams.handle.process_id(), + None, + "remote PIDs must not enter local process registries" + ); + assert_eq!(streams.handle.exit_status(), Some(Some(0))); +} + +#[test] +fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { + let host = HostFixture::new(); + let workspace = serde_json::from_value(json!({"sessionId": host.id, + "localRoot": host.root.join("placeholder"), "remoteRoot": host.root.join("host")})) + .unwrap(); + let _scope = super::super::workspace::Scope::begin(Some(workspace), "lsp.startServer").unwrap(); + let owner = super::super::workspace::current().unwrap(); + let local = url::Url::from_file_path(host.root.join("placeholder/a space.java")) + .unwrap() + .to_string(); + let remote = url::Url::from_file_path(host.root.join("host/a space.java")) + .unwrap() + .to_string(); + let value = json!({"method": "textDocument/didOpen", "params": {"textDocument": {"uri": local, + "text": format!("// {local}"), "languageId": "java", "version": 1}}}); + let mapped: Value = serde_json::from_str( + &crate::lsp::interface::remote_process::map_message(&owner, &value.to_string(), true, None) + .unwrap(), + ) + .unwrap(); + assert_eq!(mapped["params"]["textDocument"]["uri"], remote); + assert_eq!( + mapped["params"]["textDocument"]["text"], + value["params"]["textDocument"]["text"] + ); + let edit = json!({"method": "workspace/applyEdit", "params": {"edit": {"changes": {remote.clone(): [{"newText": local}]}}}}); + let mapped: Value = serde_json::from_str( + &crate::lsp::interface::remote_process::map_message(&owner, &edit.to_string(), false, None) + .unwrap(), + ) + .unwrap(); + assert_eq!( + mapped["params"]["edit"]["changes"][&local][0]["newText"], + local + ); + let map = |value: &Value, outbound, method| -> Value { + serde_json::from_str( + &crate::lsp::interface::remote_process::map_message( + &owner, + &value.to_string(), + outbound, + method, + ) + .unwrap(), + ) + .unwrap() + }; + // JSON-RPC replies have no method. The originating request keeps plugin + // values opaque while standard LSP location results still get mapped. + let reply = json!({"id": 17, "result": {"uri": remote, "data": {"uri": remote}}}); + assert_eq!(map(&reply, false, Some("lithe/function/invoke")), reply); + let mapped = map(&reply, false, Some("textDocument/definition")); + assert_eq!(mapped["result"]["uri"], local); + assert_eq!(mapped["result"]["data"], reply["result"]["data"]); + for method in [ + "lithe/function/invoke", + "lithe/host/credentials/get", + "custom/lookup", + ] { + let request = json!({"method": method, "params": {"uri": local, "data": {"uri": local}}}); + assert_eq!(map(&request, true, None), request); + } + let request = json!({"method": "initialize", "params": {"rootUri": local, + "initializationOptions": {"uri": local}, "capabilities": {"experimental": {"uri": local}}}}); + let mapped = map(&request, true, None); + assert_eq!(mapped["params"]["rootUri"], remote); + assert_eq!( + mapped["params"]["initializationOptions"], + request["params"]["initializationOptions"] + ); + assert_eq!( + mapped["params"]["capabilities"], + request["params"]["capabilities"] + ); + let request = json!({"method": "lithe/vcs/status", "params": { + "root": {"path": host.root.join("placeholder")}, + "tool": {"executable": host.root.join("placeholder/tools/svn")}, + "paths": ["dir/file"], "uri": local}}); + let mapped = map(&request, true, None); + assert_eq!( + mapped["params"]["root"]["path"], + json!(host.root.join("host")) + ); + assert_eq!( + mapped["params"]["tool"]["executable"], + json!(host.root.join("host/tools/svn")) + ); + assert_eq!(mapped["params"]["paths"], request["params"]["paths"]); + assert_eq!(mapped["params"]["uri"], local); +} diff --git a/rust/lithe-core/src/remote/tests.rs b/rust/lithe-core/src/remote/tests.rs index db5c753fd8..2910ad18ee 100644 --- a/rust/lithe-core/src/remote/tests.rs +++ b/rust/lithe-core/src/remote/tests.rs @@ -368,7 +368,8 @@ fn closed_connections_cannot_be_used() { // Application exit closes whatever is open and must be safe with nothing open. #[test] fn closing_all_connections_with_none_open_succeeds() { - assert_eq!(sessions::close_registered(), 0); + let connections = std::sync::Mutex::new(std::collections::HashMap::new()); + assert_eq!(sessions::close_registry(&connections), 0); } // ssh splits an option value on whitespace, so a key path with spaces must diff --git a/rust/lithe-core/src/remote/upload.rs b/rust/lithe-core/src/remote/upload.rs new file mode 100644 index 0000000000..b699f58f9f --- /dev/null +++ b/rust/lithe-core/src/remote/upload.rs @@ -0,0 +1,142 @@ +//! Streams a native source file to verified host staging without enlarging frames. +//! The caller owns package trust; this adapter owns transfer integrity and cleanup. + +use super::sessions::{lookup, request_error}; +use super::workspace_host::request_cancellable; +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_remote_protocol::methods; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::io::Read; +use std::path::Path; +use std::time::Duration; + +const STEP: Duration = Duration::from_secs(30); + +/// Native upload source and host destination, plus caller-verified expected hash. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Request { + session_id: String, + source_path: String, + path: String, + sha256: String, + mode: u32, +} + +/// Uses handshake facts without running another shell or using the local platform. +pub(crate) fn host_info(session_id: &str) -> Result { + let connection = lookup(session_id)?; + serde_json::to_value(&connection.session.hello().host).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid host capabilities") + .with_details(error.to_string()) + }) +} + +/// Uploads one bounded file; any failure aborts its connection-owned staging inode. +pub(crate) fn upload(request: Request) -> Result { + cancellation::check()?; + if !Path::new(&request.source_path).is_absolute() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source must be absolute", + )); + } + let mut source = std::fs::File::open(&request.source_path).map_err(source_error)?; + let metadata = source.metadata().map_err(source_error)?; + if !metadata.is_file() || metadata.len() > methods::MAX_UPLOAD_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source is not a bounded regular file", + )); + } + let connection = lookup(&request.session_id)?; + if !connection + .session + .hello() + .host + .features + .contains(&methods::Feature::Upload) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The host does not support staged uploads", + )); + } + let call = |method: &str, params: Value| { + cancellation::check()?; + request_cancellable(&connection.session, method, params, STEP) + .map_err(|error| request_error(&request.session_id, error)) + }; + let started = call( + methods::FS_UPLOAD_START, + json!({"path": request.path, "size": metadata.len(), "sha256": request.sha256, "mode": request.mode}), + )?; + let id = started["uploadId"] + .as_u64() + .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "Missing upload identity"))?; + let result = (|| { + let mut buffer = vec![0; methods::MAX_UPLOAD_CHUNK_BYTES]; + let mut offset = 0; + loop { + cancellation::check()?; + let count = source.read(&mut buffer).map_err(source_error)?; + if count == 0 { + break; + } + if offset + count as u64 > metadata.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source changed", + )); + } + let received = call( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": offset, "data": methods::encode_bytes(&buffer[..count])}), + )?; + offset += count as u64; + if received["offset"].as_u64() != Some(offset) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Unexpected upload acknowledgement", + )); + } + } + if offset != metadata.len() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Upload source changed", + )); + } + let committed = call(methods::FS_UPLOAD_COMMIT, json!({"uploadId": id}))?; + if committed["size"].as_u64() != Some(offset) + || committed["sha256"].as_str() != Some(request.sha256.to_ascii_lowercase().as_str()) + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid upload receipt", + )); + } + Ok(committed) + })(); + if result.is_err() { + // Cleanup must run even when the parent Core operation is cancelled. + if let Err(error) = connection.session.request_value( + methods::FS_UPLOAD_ABORT, + json!({"uploadId": id}), + Duration::from_secs(2), + ) { + if !matches!(&error, lithe_remote_client::session::ClientError::Remote(remote) + if remote.code == lithe_remote_protocol::ErrorCode::NotFound) + { + eprintln!("remote upload abort failed; connection staging will expire"); + } + } + } + result +} + +fn source_error(error: std::io::Error) -> CoreError { + CoreError::new(ErrorCode::Unknown, "Could not read upload source") + .with_details(error.kind().to_string()) +} diff --git a/rust/lithe-core/src/remote/workspace.rs b/rust/lithe-core/src/remote/workspace.rs new file mode 100644 index 0000000000..2c6a34b8fc --- /dev/null +++ b/rust/lithe-core/src/remote/workspace.rs @@ -0,0 +1,170 @@ +//! Request-scoped host identity for existing workspace semantic engines. +//! +//! A remote request never falls back to the placeholder filesystem or local +//! executables. Git continues to own Git semantics; this boundary supplies IO. + +use super::sessions::{lookup, Connection}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Host and path identity supplied by the native workspace registry. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Workspace { + session_id: String, + local_root: PathBuf, + remote_root: PathBuf, +} + +#[derive(Clone)] +pub(crate) struct Host { + pub(crate) connection: Arc, + workspace: Workspace, +} + +thread_local! { static HOST: RefCell> = const { RefCell::new(None) }; } + +/// Restores the caller's host for nested Core requests and error paths. +pub(crate) struct Scope(Option); + +impl Scope { + pub(crate) fn begin(workspace: Option, command: &str) -> Result { + let host = workspace + .map(|workspace| { + if !(command.starts_with("git.") + || command.starts_with("build.") + || matches!( + command, + "workspace.repositories" + | "workspace.snapshot" + | "workspace.listDirectories" + | "workspace.search" + | "workspace.searchEverywhere" + | "file.read" + | "file.write" + | "workspace.searchIndex.warm" + | "workspace.searchIndex.update" + | "workspace.searchIndex.invalidate" + | "workspace.replacePreview" + | "runConfig.selectJava" + | "lsp.startServer" + )) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "This command has no remote workspace adapter", + )); + } + if !valid_root(&workspace.local_root) || !valid_root(&workspace.remote_root) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Workspace roots must be absolute without parent components", + )); + } + Ok(Host { + connection: lookup(&workspace.session_id)?, + workspace, + }) + }) + .transpose()?; + Ok(Self(HOST.with(|current| current.replace(host)))) + } +} + +/// Installs an already validated session owner on a backend worker. +pub(crate) fn enter(host: Option) -> Scope { + Scope(HOST.with(|current| current.replace(host))) +} + +fn valid_root(path: &Path) -> bool { + path.is_absolute() + && !path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) +} + +impl Drop for Scope { + fn drop(&mut self) { + HOST.with(|current| current.replace(self.0.take())); + } +} + +pub(crate) fn current() -> Option { + HOST.with(|host| host.borrow().clone()) +} + +/// Whether filesystem and tool operations belong to a connected workspace host. +pub(crate) fn is_remote() -> bool { + current().is_some() +} + +impl Host { + /// Maps only file URIs contained by this workspace, preserving URI escaping. + pub(crate) fn map_uri(&self, value: &str, outbound: bool) -> Option { + let uri = url::Url::parse(value).ok()?; + if uri.scheme() != "file" + || uri + .host_str() + .is_some_and(|host| !host.is_empty() && host != "localhost") + { + return None; + } + let path = uri.to_file_path().ok()?; + let mapped = if outbound { + self.remote_path(&path) + } else { + self.local_path(&path) + }; + if mapped == path { + return None; + } + url::Url::from_file_path(mapped) + .ok() + .map(|uri| uri.to_string()) + } + pub(crate) fn remote_path(&self, path: &Path) -> PathBuf { + if path == self.workspace.local_root { + return self.workspace.remote_root.clone(); + } + path.strip_prefix(&self.workspace.local_root) + .map(|suffix| self.workspace.remote_root.join(suffix)) + .unwrap_or_else(|_| path.to_owned()) + } + + pub(super) fn local_path(&self, path: &Path) -> PathBuf { + if path == self.workspace.remote_root { + return self.workspace.local_root.clone(); + } + path.strip_prefix(&self.workspace.remote_root) + .map(|suffix| self.workspace.local_root.join(suffix)) + .unwrap_or_else(|_| path.to_owned()) + } + + pub(super) fn identity(&self) -> &str { + &self.workspace.session_id + } +} + +/// Projects host paths back onto the native workspace's URL identity. +pub(crate) fn local_path(path: &Path) -> PathBuf { + current() + .map(|host| host.local_path(path)) + .unwrap_or_else(|| path.to_owned()) +} + +/// Translates a schema-owned path to its host; opaque argv and text stay untouched. +pub(crate) fn remote_path(path: &Path) -> PathBuf { + current() + .map(|host| host.remote_path(path)) + .unwrap_or_else(|| path.to_owned()) +} + +/// Includes host identity in process-local repository coordination keys. +pub(crate) fn identity_key(path: &Path) -> PathBuf { + current() + .map(|host| PathBuf::from(host.identity()).join(path.strip_prefix("/").unwrap_or(path))) + .unwrap_or_else(|| path.to_owned()) +} diff --git a/rust/lithe-core/src/remote/workspace_host.rs b/rust/lithe-core/src/remote/workspace_host.rs new file mode 100644 index 0000000000..1998032c48 --- /dev/null +++ b/rust/lithe-core/src/remote/workspace_host.rs @@ -0,0 +1,572 @@ +//! Product access to the existing lightweight workspace host protocol. +//! +//! Streaming calls own their resource and subscription together. Cancellation, +//! failed creation, and caller disconnect all close the remote process tree; +//! ordinary host requests cannot create an unobserved streaming resource. + +use super::sessions::{lookup, request_error}; +use crate::git::execution_events; +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_remote_client::session::{ClientError, Event, RemoteSession}; +use lithe_remote_protocol::methods; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::VecDeque; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const EVENT_POLL: Duration = Duration::from_millis(20); +const CLEANUP_TIMEOUT: Duration = Duration::from_secs(2); +const EARLY_EVENT_LIMIT: usize = 8 * 1024 * 1024; + +/// Waits for finite host work while preserving the owning Core cancellation. +pub(crate) fn request_cancellable( + session: &RemoteSession, + method: &str, + params: Value, + timeout: Duration, +) -> Result { + let pending = session.begin_request_value(method, params, timeout)?; + loop { + if cancellation::check().is_err() { + return Err(ClientError::Protocol("Operation was cancelled".into())); + } + if let Some(value) = pending.poll(EVENT_POLL)? { + return Ok(value); + } + } +} + +/// One host operation. Parameters retain the versioned remote protocol shape. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Request { + session_id: String, + #[serde(default)] + method: String, + params: Value, + /// Optional process runtime bound; cleanup remains owned after expiry. + #[serde(default)] + timeout_milliseconds: Option, +} + +/// Forwards finite host operations and controls of already observed resources. +pub(crate) fn request(request: Request) -> Result { + if !allowed_method(&request.method) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "This host operation requires a resource owner", + )); + } + cancellation::check()?; + let connection = lookup(&request.session_id)?; + let timeout = if request.method == methods::ENVIRONMENT_RESOLVE { + // Interactive and non-interactive login probes each have a host deadline. + Duration::from_secs(60) + } else { + REQUEST_TIMEOUT + }; + let result = request_cancellable( + &connection.session, + &request.method, + request.params, + timeout, + ); + cancellation::check()?; + result.map_err(|error| request_error(&request.session_id, error)) +} + +fn allowed_method(method: &str) -> bool { + matches!( + method, + methods::FS_STAT + | methods::FS_READ_DIR + | methods::FS_READ_FILE + | methods::FS_WRITE_FILE + | methods::FS_CREATE_DIR + | methods::FS_REMOVE + | methods::FS_RENAME + | methods::FS_COPY + | methods::FS_TRASH + | methods::FS_REAL_PATH + | methods::FS_SET_PERMISSIONS + | methods::FS_VOLUME_INFO + | methods::FS_TEMP_DIRECTORY + | methods::FS_UPLOAD_START + | methods::FS_UPLOAD_CHUNK + | methods::FS_UPLOAD_COMMIT + | methods::FS_UPLOAD_ABORT + | methods::ENVIRONMENT_RESOLVE + | methods::PROCESS_WRITE_STDIN + | methods::PROCESS_CLOSE_STDIN + | methods::PROCESS_KILL + | methods::TERMINAL_WRITE + | methods::TERMINAL_RESIZE + | methods::TERMINAL_CLOSE + | methods::PORTS_LISTENING + ) +} + +/// Starts a process with a subscription installed before any output can arrive. +pub(crate) fn run_process(request: Request) -> Result { + run_resource(request, "process", methods::PROCESS_SPAWN) +} + +/// Starts a PTY with the same subscription, cancellation and cleanup ownership. +pub(crate) fn run_terminal(request: Request) -> Result { + run_resource(request, "terminal", methods::TERMINAL_OPEN) +} + +/// Owns a recursive watch until cancellation, disconnect, or root removal. +pub(crate) fn run_watch(request: Request) -> Result { + run_resource(request, "watch", methods::WATCH_START) +} + +/// Bounded byte capture for tool probes; long-lived backends use streaming calls. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct CaptureRequest { + session_id: String, + params: methods::SpawnParams, + timeout_milliseconds: u64, + max_output_bytes: usize, +} + +/// Captures one process without leaking a child on timeout or excessive output. +pub(crate) fn capture_process(request: CaptureRequest) -> Result { + if !(1..=30_000).contains(&request.timeout_milliseconds) + || !(1..=methods::MAX_TRANSFER_BYTES as usize).contains(&request.max_output_bytes) + || request.params.pipe_stdin + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid host capture limits", + )); + } + let connection = lookup(&request.session_id)?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = run_observing( + &connection.session, + Request { + session_id: request.session_id, + method: String::new(), + params: serde_json::to_value(request.params).expect("spawn parameters encode"), + timeout_milliseconds: None, + }, + "process", + methods::PROCESS_SPAWN, + None, + Some(Instant::now() + Duration::from_millis(request.timeout_milliseconds)), + &mut |event| { + if event.event == methods::EVENT_PROCESS_OUTPUT { + let output: methods::ProcessOutputEvent = event.decode().map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid host process output") + .with_details(error.to_string()) + })?; + let bytes = methods::decode_bytes(&output.data).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid host process bytes") + .with_details(error.to_string()) + })?; + if stdout.len() + stderr.len() + bytes.len() > request.max_output_bytes { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Host process output exceeded its limit", + )); + } + match output.stream { + methods::OutputStream::Stdout => stdout.extend_from_slice(&bytes), + methods::OutputStream::Stderr => stderr.extend_from_slice(&bytes), + } + } + Ok(()) + }, + )?; + Ok( + json!({"stdout": methods::encode_bytes(&stdout), "stderr": methods::encode_bytes(&stderr), + "exitCode": exit["exitCode"], "signal": exit["signal"]}), + ) +} + +struct Resource<'a> { + session: &'a RemoteSession, + family: &'static str, + id: u64, + /// A lost spawn response can still mean the host created the resource. + creation_attempted: bool, +} + +impl Drop for Resource<'_> { + fn drop(&mut self) { + // Drop the receiver before the cleanup request: a full subscriber must + // never block the protocol reader from delivering the close response. + self.session.unsubscribe(self.family, self.id); + if self.creation_attempted { + let (method, params) = match self.family { + "terminal" => (methods::TERMINAL_CLOSE, json!({"terminalId": self.id})), + "watch" => (methods::WATCH_STOP, json!({"watchId": self.id})), + _ => ( + methods::PROCESS_KILL, + json!({"processId": self.id, "force": true}), + ), + }; + let _ = self.session.request_value(method, params, CLEANUP_TIMEOUT); + } + } +} + +fn run_resource(request: Request, family: &'static str, method: &str) -> Result { + cancellation::check()?; + let connection = lookup(&request.session_id)?; + run_on_session(&connection.session, request, family, method) +} + +fn run_on_session( + session: &RemoteSession, + request: Request, + family: &'static str, + method: &str, +) -> Result { + let deadline = request + .timeout_milliseconds + .map(|timeout| Instant::now() + Duration::from_millis(timeout.min(86_400_000))); + run_observing( + session, + request, + family, + method, + None, + deadline, + &mut |event| { + execution_events::emit(json!({ + "type": "remote", "event": event.event, "params": event.params, + })); + Ok(()) + }, + ) +} + +/// Runs an owned host process with byte-preserving output and optional input. +pub(crate) fn process_observing( + session: &RemoteSession, + params: methods::SpawnParams, + input: Option<&[u8]>, + observer: &mut impl FnMut(Event) -> Result<(), CoreError>, +) -> Result { + run_observing( + session, + Request { + session_id: String::new(), + method: String::new(), + params: serde_json::to_value(params).expect("spawn parameters encode"), + timeout_milliseconds: None, + }, + "process", + methods::PROCESS_SPAWN, + input, + None, + observer, + ) +} + +fn run_observing( + session: &RemoteSession, + request: Request, + family: &'static str, + method: &str, + input: Option<&[u8]>, + deadline: Option, + observer: &mut impl FnMut(Event) -> Result<(), CoreError>, +) -> Result { + let id = session.allocate_id(); + let key = format!("{family}Id"); + let mut params = request.params; + params + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Host parameters must be an object", + ) + })? + .insert(key.clone(), json!(id)); + // Validate before subscribing or sending. The host allocates IDs; callers + // cannot steal another resource by supplying an ID in their parameters. + match family { + "terminal" => { + decode::(params.clone())?; + } + "watch" => { + decode::(params.clone())?; + } + _ => { + decode::(params.clone())?; + } + } + let resource = Resource { + session, + family, + id, + creation_attempted: true, + }; + let events = session.subscribe(family, id); + let pending = session + .begin_request_value(method, params, REQUEST_TIMEOUT) + .map_err(|error| request_error(&request.session_id, error))?; + let mut early = VecDeque::new(); + let mut early_bytes = 0usize; + let started = loop { + cancellation::check()?; + check_deadline(deadline)?; + if let Some(value) = pending + .poll(EVENT_POLL) + .map_err(|error| request_error(&request.session_id, error))? + { + break value; + } + while let Ok(event) = events.try_recv() { + early_bytes = early_bytes.saturating_add(event.params.to_string().len()); + if early_bytes > EARLY_EVENT_LIMIT { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Host startup output exceeded its limit", + )); + } + early.push_back(event); + } + }; + observer(Event { + event: format!("{family}.started"), + params: json!({key: id, "pid": started.get("pid")}), + seq: None, + })?; + let outcome = receive_events( + &resource, + &events, + early, + &request.session_id, + input, + deadline, + observer, + ); + // See Resource::drop: remove backpressure before trying to stop the tree. + drop(events); + drop(resource); + outcome +} + +fn receive_events( + resource: &Resource<'_>, + events: &Receiver, + mut early: VecDeque, + session_id: &str, + mut input: Option<&[u8]>, + deadline: Option, + observer: &mut impl FnMut(Event) -> Result<(), CoreError>, +) -> Result { + let exit = format!("{}.exit", resource.family); + let mut writing = None; + let mut write_count = 0usize; + loop { + cancellation::check()?; + check_deadline(deadline)?; + let mut observed = 0; + while let Ok(event) = early + .pop_front() + .map(Ok) + .unwrap_or_else(|| events.try_recv()) + { + validate_event(resource, &event)?; + let finished = event.event == exit || watch_ended(&event); + let result = event.params.clone(); + observer(event)?; + if finished { + return Ok(result); + } + observed += 1; + if observed >= 32 { + break; + } + } + if let Some(pending) = &writing { + match lithe_remote_client::session::PendingRequest::poll(pending, Duration::ZERO) { + Ok(Some(_)) => { + input = if write_count == 0 { + None + } else { + input.map(|bytes| &bytes[write_count..]) + }; + writing = None; + } + Ok(None) => {} + Err(ClientError::Remote(error)) + if error.code == lithe_remote_protocol::ErrorCode::Busy => + { + writing = None + } + // A child can finish between its input and exit event. Drain + // the authoritative exit before treating a closed pipe as failure. + Err(ClientError::Remote(error)) + if matches!( + error.code, + lithe_remote_protocol::ErrorCode::UnknownResource + | lithe_remote_protocol::ErrorCode::InvalidRequest + ) => + { + input = None; + writing = None; + } + Err(error) => return Err(request_error(session_id, error)), + } + } + if let Some(remaining) = input.filter(|_| writing.is_none()) { + let count = remaining.len().min(32 * 1024); + let (method, params) = if count == 0 { + ( + methods::PROCESS_CLOSE_STDIN, + json!({"processId": resource.id}), + ) + } else { + ( + methods::PROCESS_WRITE_STDIN, + json!({"processId": resource.id, + "data": methods::encode_bytes(&remaining[..count])}), + ) + }; + write_count = count; + writing = Some( + resource + .session + .begin_request_value(method, params, REQUEST_TIMEOUT) + .map_err(|error| request_error(session_id, error))?, + ); + } + match events.recv_timeout(EVENT_POLL) { + Ok(event) => { + validate_event(resource, &event)?; + let finished = event.event == exit || watch_ended(&event); + let result = event.params.clone(); + observer(event)?; + if finished { + return Ok(result); + } + } + Err(RecvTimeoutError::Timeout) if !resource.session.is_closed() => {} + Err(_) => { + return Err(request_error( + session_id, + ClientError::Disconnected { + diagnostics: String::new(), + }, + )) + } + } + } +} + +fn check_deadline(deadline: Option) -> Result<(), CoreError> { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(CoreError::new( + ErrorCode::TimedOut, + "Host process timed out", + )) + } else { + Ok(()) + } +} + +fn watch_ended(event: &Event) -> bool { + event.event == methods::EVENT_WATCH_ERROR && event.params["code"] == "ROOT_REMOVED" +} + +fn validate_event(resource: &Resource<'_>, event: &Event) -> Result<(), CoreError> { + if event.resource() != Some((resource.family, resource.id)) { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Host resource identity changed", + )); + } + let valid_data = |data: &str| { + methods::decode_bytes(data) + .is_ok_and(|bytes| bytes.len() <= methods::MAX_TRANSFER_BYTES as usize) + }; + let valid = match event.event.as_str() { + methods::EVENT_PROCESS_OUTPUT => event + .decode::() + .is_ok_and(|event| valid_data(&event.data)), + methods::EVENT_PROCESS_EXIT => event.decode::().is_ok(), + methods::EVENT_TERMINAL_OUTPUT => event + .decode::() + .is_ok_and(|event| valid_data(&event.data)), + methods::EVENT_TERMINAL_EXIT => event.decode::().is_ok(), + methods::EVENT_WATCH_CHANGED => event.decode::().is_ok(), + methods::EVENT_WATCH_ERROR => event.decode::().is_ok(), + _ => false, + }; + if !valid { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Invalid host resource event", + )); + } + Ok(()) +} + +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid host resource parameters", + ) + .with_details(error.to_string()) + }) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod request_tests { + use super::*; + + #[test] + fn finite_requests_cannot_create_unobserved_resources_or_change_session_delivery() { + for method in [ + methods::PROCESS_SPAWN, + methods::TERMINAL_OPEN, + methods::WALK_START, + methods::WATCH_START, + methods::TUNNEL_OPEN, + methods::SERVER_SHUTDOWN, + methods::SERVER_ACK, + methods::SERVER_RESUME, + ] { + assert!(!allowed_method(method), "{method}"); + } + for method in [ + methods::FS_STAT, + methods::ENVIRONMENT_RESOLVE, + methods::PROCESS_WRITE_STDIN, + methods::PROCESS_KILL, + methods::TERMINAL_RESIZE, + methods::TERMINAL_CLOSE, + ] { + assert!(allowed_method(method), "{method}"); + } + } + + #[test] + fn host_request_rejects_unowned_operations_before_looking_up_a_connection() { + let result = request(Request { + session_id: "missing-session".into(), + method: methods::TERMINAL_OPEN.into(), + params: json!({}), + timeout_milliseconds: None, + }); + assert!(matches!( + result.unwrap_err().code, + ErrorCode::InvalidRequest + )); + } +} diff --git a/rust/lithe-core/src/remote/workspace_host/tests.rs b/rust/lithe-core/src/remote/workspace_host/tests.rs new file mode 100644 index 0000000000..b3223ee777 --- /dev/null +++ b/rust/lithe-core/src/remote/workspace_host/tests.rs @@ -0,0 +1,314 @@ +//! Socket-level regressions for product resource ownership and event ordering. + +#![cfg(unix)] + +use super::*; +use lithe_remote_client::ConnectOptions; +use lithe_remote_protocol::frame::{read_frame, write_frame}; +use lithe_remote_protocol::{Message, PREAMBLE}; +use std::io::Write; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; +use std::sync::{mpsc, Arc, Mutex}; + +const STEP: Duration = Duration::from_secs(3); + +/// A real framed session with a deterministic peer. Socket deadlines and an +/// explicit completion signal bound failed assertions and thread cleanup. +struct Peer { + session: RemoteSession, + socket: UnixStream, + completed: mpsc::Receiver>, + thread: Option>, +} + +impl Peer { + fn new(exit: bool) -> Self { + Self::with_watch(exit, false) + } + + fn with_watch(exit: bool, watch: bool) -> Self { + let (client, mut server) = UnixStream::pair().unwrap(); + server.set_read_timeout(Some(STEP)).unwrap(); + server.set_write_timeout(Some(STEP)).unwrap(); + let socket = client.try_clone().unwrap(); + let (done, completed) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let mut seen = Vec::new(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + server.write_all(PREAMBLE).unwrap(); + while let Ok(Some(frame)) = read_frame(&mut server) { + let Message::Request { id, method, params } = + serde_json::from_slice(&frame).unwrap() + else { + continue; + }; + seen.push(method.clone()); + let result = match method.as_str() { + methods::SERVER_HELLO => json!({ + "protocolVersion": 1, "serverVersion": "test", + "host": {"os": "linux", "arch": "x86_64", "hostName": "test-host", + "userName": "dev", "homeDirectory": "/home/dev", "defaultShell": "/bin/sh", + "pathSeparator": "/", "process": {"orphanProtection": "parentDeathSignal"}, + "watch": {"backend": "inotify", "directoryLimit": 128}, "features": []}, + "session": {"persistent": false, "resumed": false, "nextResourceId": 7, "lastEventSeq": 0} + }), + methods::SERVER_RESUME => json!({"truncated": false}), + methods::PROCESS_SPAWN => { + // A fast child writes and exits before spawn's reply. + // The resource subscription must already exist. + let process = params["processId"].as_u64().unwrap(); + assert_eq!(process, 7); + send( + &mut server, + Message::Event { + event: methods::EVENT_PROCESS_OUTPUT.into(), + params: json!({"processId": process, "stream": "stdout", "data": methods::encode_bytes(b"early\0output")}), + seq: None, + }, + ); + if exit { + send( + &mut server, + Message::Event { + event: methods::EVENT_PROCESS_EXIT.into(), + params: json!({"processId": process, "exitCode": 19, "signal": null}), + seq: None, + }, + ); + } + json!({"pid": 101}) + } + methods::WATCH_START if watch => { + assert_eq!(params["watchId"], 7); + send( + &mut server, + Message::Event { + event: methods::EVENT_WATCH_CHANGED.into(), + params: json!({"watchId": 7, "changes": [{"path": "/workspace/a", "kind": "modified"}], "overflow": true}), + seq: None, + }, + ); + if exit { + send( + &mut server, + Message::Event { + event: methods::EVENT_WATCH_ERROR.into(), + params: json!({"watchId": 7, "code": "ROOT_REMOVED", "message": "root moved"}), + seq: None, + }, + ); + } + json!({"watchedDirectories": 1}) + } + methods::PROCESS_KILL | methods::WATCH_STOP => { + assert_eq!( + params, + if watch { + json!({"watchId": 7}) + } else { + json!({"processId": 7, "force": true}) + } + ); + send(&mut server, Message::success(id, Value::Null)); + break; + } + _ => panic!("unexpected method {method}"), + }; + send(&mut server, Message::success(id, result)); + } + })); + // Signal before propagating a peer assertion so teardown can join. + let _ = done.send(seen); + if let Err(error) = result { + std::panic::resume_unwind(error); + } + }); + let (session, events) = RemoteSession::from_streams( + client.try_clone().unwrap(), + client, + &ConnectOptions { + handshake_timeout: STEP, + ..ConnectOptions::default() + }, + ) + .unwrap(); + drop(events); + Self { + session, + socket, + completed, + thread: Some(thread), + } + } + + fn finish(&mut self) -> Vec { + let seen = self + .completed + .recv_timeout(STEP) + .expect("peer must finish within its socket deadline"); + self.thread.take().unwrap().join().unwrap(); + seen + } +} + +#[test] +fn watch_changes_before_start_reply_are_delivered_and_removed_root_stops_subscription() { + let mut peer = Peer::with_watch(true, true); + let _scope = cancellation::Scope::begin(Some("remote-watch-root".into()), Some(3_000)); + let observed = Arc::new(Mutex::new(Vec::::new())); + let records = Arc::clone(&observed); + let sink = Arc::new(move |raw: &str| { + records + .lock() + .unwrap() + .push(serde_json::from_str(raw).unwrap()); + }); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + Request { + session_id: "socket-test".into(), + method: String::new(), + timeout_milliseconds: None, + params: json!({"root": "/workspace"}), + }, + "watch", + methods::WATCH_START, + ) + }) + .unwrap(); + assert_eq!(result["code"], "ROOT_REMOVED"); + let records = observed.lock().unwrap(); + assert_eq!( + records + .iter() + .map(|value| value["event"].as_str().unwrap()) + .collect::>(), + ["watch.started", "watch.changed", "watch.error"] + ); + assert_eq!(records[1]["params"]["overflow"], true); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::WATCH_STOP) + ); +} + +#[test] +fn cancellation_of_a_watch_stops_its_host_resource_before_returning() { + let mut peer = Peer::with_watch(false, true); + let _scope = cancellation::Scope::begin(Some("remote-watch-cancel".into()), Some(3_000)); + let sink = Arc::new(|raw: &str| { + let event: Value = serde_json::from_str(raw).unwrap(); + if event["event"] == "watch.started" { + assert!(cancellation::cancel("remote-watch-cancel")); + } + }); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + Request { + session_id: "socket-test".into(), + method: String::new(), + timeout_milliseconds: None, + params: json!({"root": "/workspace"}), + }, + "watch", + methods::WATCH_START, + ) + }); + assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::WATCH_STOP) + ); +} + +impl Drop for Peer { + fn drop(&mut self) { + let _ = self.socket.shutdown(Shutdown::Both); + if self.thread.is_some() && self.completed.recv_timeout(STEP + STEP).is_ok() { + let _ = self.thread.take().unwrap().join(); + } + } +} + +fn send(socket: &mut UnixStream, message: Message) { + write_frame(socket, &serde_json::to_vec(&message).unwrap()).unwrap(); + socket.flush().unwrap(); +} + +fn spawn_request() -> Request { + Request { + session_id: "socket-test".into(), + method: String::new(), + timeout_milliseconds: None, + params: json!({"argv": ["test-tool"], "cwd": "/workspace", "processId": 999}), + } +} + +#[test] +fn early_binary_output_and_exit_are_ordered_after_started_and_tree_is_cleaned() { + let mut peer = Peer::new(true); + let observed = Arc::new(Mutex::new(Vec::::new())); + let records = Arc::clone(&observed); + let sink = Arc::new(move |raw: &str| { + records + .lock() + .unwrap() + .push(serde_json::from_str(raw).unwrap()) + }); + let _scope = cancellation::Scope::begin(Some("remote-early-output".into()), Some(3_000)); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + spawn_request(), + "process", + methods::PROCESS_SPAWN, + ) + }) + .unwrap(); + assert_eq!(result["exitCode"], 19); + let records = observed.lock().unwrap(); + assert_eq!( + records + .iter() + .map(|value| value["event"].as_str().unwrap()) + .collect::>(), + ["process.started", "process.output", "process.exit"] + ); + assert_eq!(records[0]["params"]["processId"], 7); + assert_eq!( + methods::decode_bytes(records[1]["params"]["data"].as_str().unwrap()).unwrap(), + b"early\0output" + ); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::PROCESS_KILL) + ); +} + +#[test] +fn cancellation_after_started_kills_the_owned_tree_before_returning() { + let mut peer = Peer::new(false); + let _scope = cancellation::Scope::begin(Some("remote-cancel-resource".into()), Some(3_000)); + let sink = Arc::new(|raw: &str| { + let event: Value = serde_json::from_str(raw).unwrap(); + if event["event"] == "process.started" { + assert!(cancellation::cancel("remote-cancel-resource")); + } + }); + let result = execution_events::with_sink(sink, || { + run_on_session( + &peer.session, + spawn_request(), + "process", + methods::PROCESS_SPAWN, + ) + }); + assert!(matches!(result.unwrap_err().code, ErrorCode::Cancelled)); + assert_eq!( + peer.finish().last().map(String::as_str), + Some(methods::PROCESS_KILL) + ); +} diff --git a/rust/lithe-core/src/remote/workspace_io.rs b/rust/lithe-core/src/remote/workspace_io.rs new file mode 100644 index 0000000000..b6a101590a --- /dev/null +++ b/rust/lithe-core/src/remote/workspace_io.rs @@ -0,0 +1,624 @@ +//! Filesystem adapter for workspace engines, with no local fallback on remote errors. + +use super::workspace::{self, Host}; +use lithe_remote_client::session::ClientError; +use lithe_remote_protocol::{methods, ErrorCode}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// Materialized workspace reads share the editor's 32 MiB budget. Streaming +// consumers (for example snapshot hashing) can still read larger files. +const MAX_READ_BYTES: u64 = 32 * 1024 * 1024; + +fn rpc(host: &Host, method: &str, params: Value) -> io::Result { + crate::protocol::cancellation::check().map_err(|error| io::Error::other(error.message))?; + let result = super::workspace_host::request_cancellable( + &host.connection.session, + method, + params, + Duration::from_secs(30), + ); + crate::protocol::cancellation::check() + .map_err(|error| io::Error::new(io::ErrorKind::Interrupted, error.message))?; + let value = result.map_err(|error| { + let kind = match &error { + ClientError::Remote(error) => match error.code { + ErrorCode::NotFound => io::ErrorKind::NotFound, + ErrorCode::AlreadyExists => io::ErrorKind::AlreadyExists, + ErrorCode::PermissionDenied => io::ErrorKind::PermissionDenied, + ErrorCode::NotADirectory => io::ErrorKind::NotADirectory, + ErrorCode::Conflict => io::ErrorKind::WouldBlock, + _ => io::ErrorKind::Other, + }, + ClientError::Timeout => io::ErrorKind::TimedOut, + _ => io::ErrorKind::ConnectionAborted, + }; + io::Error::new(kind, error) + })?; + serde_json::from_value(value).map_err(io::Error::other) +} + +fn path(host: &Host, path: &Path) -> io::Result { + host.remote_path(path) + .to_str() + .map(str::to_owned) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Remote paths must be UTF-8")) +} + +/// Metadata retaining the host's file kind and permissions. +pub(crate) struct Metadata { + kind: methods::FileKind, + size: u64, + mode: u32, + modified: Option, +} +impl Metadata { + /// Size in bytes as reported by the owning filesystem. + pub(crate) fn len(&self) -> u64 { + self.size + } + /// Whether the entry itself is a regular file. + pub(crate) fn is_file(&self) -> bool { + self.kind == methods::FileKind::File + } + /// Whether the entry itself is a directory. + pub(crate) fn is_dir(&self) -> bool { + self.kind == methods::FileKind::Directory + } + /// Kind of the entry itself, without following symbolic links. + pub(crate) fn file_type(&self) -> FileType { + FileType(self.kind) + } + /// POSIX mode used by guarded Git snapshots. + pub(crate) fn mode(&self) -> u32 { + self.mode + } + /// Host modification time; absent timestamps are not treated as fresh output. + pub(crate) fn modified(&self) -> io::Result { + self.modified.ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "Modification time is unavailable", + ) + }) + } +} + +/// Filesystem entry kind independent of the client operating system. +pub(crate) struct FileType(methods::FileKind); +impl FileType { + /// Whether the entry is a regular file, without following links. + pub(crate) fn is_file(&self) -> bool { + self.0 == methods::FileKind::File + } + /// Whether the entry is a directory, without following links. + pub(crate) fn is_dir(&self) -> bool { + self.0 == methods::FileKind::Directory + } + /// Whether the entry is a symbolic link. + pub(crate) fn is_symlink(&self) -> bool { + self.0 == methods::FileKind::Symlink + } +} + +fn native_metadata(metadata: std::fs::Metadata) -> Metadata { + let kind = if metadata.is_dir() { + methods::FileKind::Directory + } else if metadata.is_file() { + methods::FileKind::File + } else if metadata.is_symlink() { + methods::FileKind::Symlink + } else { + methods::FileKind::Other + }; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() + }; + #[cfg(not(unix))] + let mode = 0; + Metadata { + kind, + size: metadata.len(), + mode, + modified: metadata.modified().ok(), + } +} + +fn remote_metadata(metadata: methods::FileMetadata) -> Metadata { + Metadata { + kind: metadata.kind, + size: metadata.size, + mode: metadata.mode, + modified: metadata.modified_ms.and_then(|millis| { + let offset = Duration::from_millis(millis.unsigned_abs()); + if millis >= 0 { + UNIX_EPOCH.checked_add(offset) + } else { + UNIX_EPOCH.checked_sub(offset) + } + }), + } +} + +/// Reads metadata of the entry itself on the workspace host. +pub(crate) fn symlink_metadata(path_value: impl AsRef) -> io::Result { + let path_value = path_value.as_ref(); + match workspace::current() { + Some(host) => rpc::( + &host, + methods::FS_STAT, + json!({"path": path(&host, path_value)?}), + ) + .map(remote_metadata), + None => std::fs::symlink_metadata(path_value).map(native_metadata), + } +} + +/// Reads target metadata, resolving symbolic links on the owning host. +pub(crate) fn metadata(path_value: impl AsRef) -> io::Result { + if workspace::is_remote() { + let value = symlink_metadata(path_value.as_ref())?; + if value.file_type().is_symlink() { + return symlink_metadata(canonicalize(path_value)?); + } + Ok(value) + } else { + std::fs::metadata(path_value).map(native_metadata) + } +} + +/// Tests existence without treating host failures as a missing path. +pub(crate) fn try_exists(path_value: impl AsRef) -> io::Result { + match metadata(path_value) { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} +/// Tests whether a path is a directory without suppressing host failures. +pub(crate) fn is_dir(path_value: impl AsRef) -> io::Result { + match metadata(path_value) { + Ok(value) => Ok(value.is_dir()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} +/// Tests whether a path is a regular file on its owning filesystem. +pub(crate) fn is_file(path_value: impl AsRef) -> io::Result { + match metadata(path_value) { + Ok(value) => Ok(value.is_file()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +/// Resolves links while preserving the native workspace URL identity. +pub(crate) fn canonicalize(path_value: impl AsRef) -> io::Result { + let path_value = path_value.as_ref(); + match workspace::current() { + Some(host) => { + let result: methods::RealPathResult = rpc( + &host, + methods::FS_REAL_PATH, + json!({"path": path(&host, path_value)?}), + )?; + Ok(host.local_path(Path::new(&result.path))) + } + None => path_value.canonicalize(), + } +} + +/// Reads a link's stored target without resolving it. +pub(crate) fn read_link(path_value: impl AsRef) -> io::Result { + match workspace::current() { + Some(host) => { + let metadata: methods::FileMetadata = rpc( + &host, + methods::FS_STAT, + json!({"path": path(&host, path_value.as_ref())?}), + )?; + metadata.symlink_target.map(PathBuf::from).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Path is not a readable symbolic link", + ) + }) + } + None => std::fs::read_link(path_value), + } +} + +/// Streaming file reader; remote reads use bounded chunks rather than mirroring a file. +pub(crate) struct File(FileReader); +enum FileReader { + Local(std::fs::File), + Remote { + host: Host, + path: String, + metadata: Metadata, + offset: u64, + /// Version captured by the first read, not a separate path stat. + version: Option, + eof: bool, + }, +} +impl File { + /// Opens a regular file on its owning host. + pub(crate) fn open(path_value: impl AsRef) -> io::Result { + match workspace::current() { + Some(host) => { + let metadata = metadata(path_value.as_ref())?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Expected a regular file", + )); + } + let path = path(&host, path_value.as_ref())?; + Ok(Self(FileReader::Remote { + host, + path, + metadata, + offset: 0, + version: None, + eof: false, + })) + } + None => std::fs::File::open(path_value).map(|file| Self(FileReader::Local(file))), + } + } + /// Metadata of this opened file, without a separate native filesystem lookup. + pub(crate) fn metadata(&self) -> io::Result { + match &self.0 { + FileReader::Local(file) => file.metadata().map(native_metadata), + FileReader::Remote { metadata, .. } => Ok(Metadata { + kind: metadata.kind, + size: metadata.size, + mode: metadata.mode, + modified: metadata.modified, + }), + } + } +} +impl Read for File { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + match &mut self.0 { + FileReader::Local(file) => file.read(buffer), + FileReader::Remote { + host, + path, + offset, + version, + eof, + .. + } => { + if buffer.is_empty() || *eof { + return Ok(0); + } + let result: methods::ReadFileResult = rpc( + host, + methods::FS_READ_FILE, + json!({"path": path, "offset": offset, "length": buffer.len().min(1024 * 1024)}), + )?; + let observed = methods::FileVersion { + size: result.metadata.size, + modified_ms: result.metadata.modified_ms, + }; + if version.as_ref().is_some_and(|first| *first != observed) { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "Remote file changed while being read", + )); + } + let bytes = methods::decode_bytes(&result.data).map_err(io::Error::other)?; + let next_offset = offset.checked_add(bytes.len() as u64).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid remote read offset") + })?; + if bytes.len() > buffer.len().min(1024 * 1024) + || next_offset > observed.size + || result.eof != (next_offset == observed.size) + || (bytes.is_empty() && !result.eof) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Invalid remote read size", + )); + } + *version = Some(observed); + *eof = result.eof; + buffer[..bytes.len()].copy_from_slice(&bytes); + *offset = next_offset; + Ok(bytes.len()) + } + } + } +} + +/// Reads file bytes through the selected host adapter. +pub(crate) fn read(path_value: impl AsRef) -> io::Result> { + let path_value = path_value.as_ref(); + if !workspace::is_remote() { + return std::fs::read(path_value); + } + if metadata(path_value)?.len() > MAX_READ_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Remote file exceeds the 32 MiB read limit", + )); + } + let mut bytes = Vec::new(); + // The second bound also covers growth after the metadata request. + File::open(path_value)? + .take(MAX_READ_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_READ_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Remote file exceeds the 32 MiB read limit", + )); + } + Ok(bytes) +} +/// Reads UTF-8 text through the selected host adapter. +pub(crate) fn read_to_string(path_value: impl AsRef) -> io::Result { + String::from_utf8(read(path_value)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} +/// Atomically replaces remote content; local behavior retains standard filesystem semantics. +pub(crate) fn write(path_value: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + match workspace::current() { + Some(host) => { + check_write_size(bytes.as_ref().len() as u64)?; + rpc::( + &host, + methods::FS_WRITE_FILE, + json!({"path": path(&host, path_value.as_ref())?, + "data": methods::encode_bytes(bytes.as_ref()), "overwrite": true}), + ) + .map(|_| ()) + } + None => std::fs::write(path_value, bytes), + } +} +/// Writes and synchronizes a session record before publishing its manifest. +pub(crate) fn write_synced(path_value: &Path, bytes: &[u8]) -> io::Result<()> { + if workspace::is_remote() { + return write(path_value, bytes); + } + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path_value)?; + file.write_all(bytes)?; + file.sync_all() +} +/// Appends ignore rules, rejecting remote changes since the read used for this append. +pub(crate) fn append(path_value: &Path, bytes: &[u8]) -> io::Result<()> { + match workspace::current() { + Some(host) => { + let remote = path(&host, path_value)?; + let previous: Option = + match rpc(&host, methods::FS_STAT, json!({"path": remote})) { + Ok(value) => Some(value), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let mut content = if let Some(previous) = &previous { + check_write_size(previous.size.saturating_add(bytes.len() as u64))?; + read(path_value)? + } else { + Vec::new() + }; + content.extend_from_slice(bytes); + check_write_size(content.len() as u64)?; + rpc::(&host, methods::FS_WRITE_FILE, json!({"path": remote, "data": methods::encode_bytes(&content), + "expected": previous.map(|value| methods::FileVersion { size: value.size, modified_ms: value.modified_ms })})).map(|_| ()) + } + None => { + use std::io::Write; + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path_value)? + .write_all(bytes) + } + } +} + +fn check_write_size(length: u64) -> io::Result<()> { + if length > methods::MAX_TRANSFER_BYTES { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Remote file exceeds the 8 MiB atomic write limit", + )) + } else { + Ok(()) + } +} + +/// Creates one directory with optional permissions applied at creation. +pub(crate) fn create_dir_mode( + path_value: &Path, + recursive: bool, + mode: Option, +) -> io::Result<()> { + match workspace::current() { + Some(host) => rpc::( + &host, + methods::FS_CREATE_DIR, + json!({"path": path(&host, path_value)?, "recursive": recursive, "mode": mode}), + ) + .map(|_| ()), + None => { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(recursive); + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::DirBuilderExt; + builder.mode(mode); + } + builder.create(path_value) + } + } +} +/// Creates a directory without recursively creating parents. +pub(crate) fn create_dir(path_value: impl AsRef) -> io::Result<()> { + create_dir_mode(path_value.as_ref(), false, None) +} +/// Creates a directory and any missing parents. +pub(crate) fn create_dir_all(path_value: impl AsRef) -> io::Result<()> { + create_dir_mode(path_value.as_ref(), true, None) +} +/// Removes a disposable directory tree from its owning host. +pub(crate) fn remove_dir_all(path_value: impl AsRef) -> io::Result<()> { + match workspace::current() { + Some(host) => rpc::( + &host, + methods::FS_REMOVE, + json!({"path": path(&host, path_value.as_ref())?, "recursive": true}), + ) + .map(|_| ()), + None => std::fs::remove_dir_all(path_value), + } +} + +/// Deletes owned scratch state even after the user cancels its operation. +pub(crate) fn cleanup_directory(path_value: &Path) { + let result = if let Some(host) = workspace::current() { + path(&host, path_value).and_then(|path| { + host.connection + .session + .request_value( + methods::FS_REMOVE, + json!({"path": path, "recursive": true}), + Duration::from_secs(2), + ) + .map(|_| ()) + .map_err(|error| { + let kind = match &error { + ClientError::Remote(remote) if remote.code == ErrorCode::NotFound => { + io::ErrorKind::NotFound + } + _ => io::ErrorKind::Other, + }; + io::Error::new(kind, error) + }) + }) + } else { + std::fs::remove_dir_all(path_value) + }; + if let Err(error) = result { + if error.kind() != io::ErrorKind::NotFound { + // Scratch names can identify user paths; log only the error category. + eprintln!( + "Could not clean workspace scratch directory: {:?}", + error.kind() + ); + } + } +} +/// Renames a file on its owning host, replacing an existing target. +pub(crate) fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + match workspace::current() { + Some(host) => rpc::(&host, methods::FS_RENAME, json!({"from": path(&host, from.as_ref())?, "to": path(&host, to.as_ref())?, "overwrite": true})).map(|_| ()), + None => std::fs::rename(from, to), + } +} +/// Copies a file on the host without transferring its content through the client. +pub(crate) fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { + match workspace::current() { + Some(host) => { + rpc::( + &host, + methods::FS_COPY, + json!({"from": path(&host, from.as_ref())?, "to": path(&host, to.as_ref())?, "overwrite": true}), + )?; + Ok(metadata(to)?.len()) + } + None => std::fs::copy(from, to), + } +} + +/// Directory entry preserving the client's workspace path identity. +pub(crate) struct DirEntry { + path: PathBuf, + kind: methods::FileKind, +} +impl DirEntry { + /// Name of this entry, without its parent path. + pub(crate) fn file_name(&self) -> std::ffi::OsString { + self.path.file_name().unwrap_or_default().to_owned() + } + /// Entry path in native workspace identity. + pub(crate) fn path(&self) -> PathBuf { + self.path.clone() + } + /// File type, without following symbolic links. + pub(crate) fn file_type(&self) -> io::Result { + Ok(FileType(self.kind)) + } +} +/// Lists a directory through the selected workspace adapter. +pub(crate) fn read_dir( + path_value: impl AsRef, +) -> io::Result>> { + let path_value = path_value.as_ref(); + match workspace::current() { + Some(host) => { + let result: methods::ReadDirResult = rpc( + &host, + methods::FS_READ_DIR, + json!({"path": path(&host, path_value)?}), + )?; + if result.skipped_non_utf8 != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Remote directory has unsupported non-UTF-8 names", + )); + } + Ok(result + .entries + .into_iter() + .map(|entry| { + Ok(DirEntry { + path: path_value.join(entry.name), + kind: entry.metadata.kind, + }) + }) + .collect::>() + .into_iter()) + } + None => Ok(std::fs::read_dir(path_value)? + .map(|entry| { + let entry = entry?; + let metadata = native_metadata(std::fs::symlink_metadata(entry.path())?); + Ok(DirEntry { + path: entry.path(), + kind: metadata.kind, + }) + }) + .collect::>() + .into_iter()), + } +} + +/// Allocates a private host scratch directory; its caller owns deletion. +pub(crate) fn remote_temp_directory(prefix: &str) -> io::Result> { + workspace::current() + .map(|host| { + rpc::( + &host, + methods::FS_TEMP_DIRECTORY, + json!({"prefix": prefix}), + ) + .map(|value| PathBuf::from(value.path)) + }) + .transpose() +} diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 595245c85a..950cf7e1a1 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -108,6 +108,11 @@ fn execute(request: &str) -> CoreResponse { if let Err(error) = crate::protocol::cancellation::check() { return CoreResponse::failure(id, error); } + let _workspace = + match crate::remote::workspace::Scope::begin(parsed.remote_workspace, &parsed.command) { + Ok(scope) => scope, + Err(error) => return CoreResponse::failure(id, error), + }; let _git_policy = match crate::git::execution_policy::Scope::begin(parsed.git_execution) { Ok(scope) => scope, Err(error) => return CoreResponse::failure(id, error), @@ -2436,9 +2441,10 @@ fn execute(request: &str) -> CoreResponse { ), } } - CoreCommand::GitExecutable => { - CoreResponse::success(id, git::configuration::locate_executable()) - } + CoreCommand::GitExecutable => match git::configuration::locate_executable() { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + }, CoreCommand::RemoteProbe => { match serde_json::from_value::(parsed.payload) { Ok(request) => match crate::remote::probe(request) { @@ -2518,6 +2524,63 @@ fn execute(request: &str) -> CoreResponse { ), } } + CoreCommand::RemoteCaptureProcess => { + match serde_json::from_value(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid host capture request") + .with_details(error.to_string()) + }) + .and_then(crate::remote::workspace_host::capture_process) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RemoteHostRequest + | CoreCommand::RemoteRunProcess + | CoreCommand::RemoteRunWatch + | CoreCommand::RemoteRunTerminal => { + let operation = match command { + CoreCommand::RemoteHostRequest => crate::remote::workspace_host::request, + CoreCommand::RemoteRunProcess => crate::remote::workspace_host::run_process, + CoreCommand::RemoteRunWatch => crate::remote::workspace_host::run_watch, + _ => crate::remote::workspace_host::run_terminal, + }; + match serde_json::from_value(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace host request") + .with_details(error.to_string()) + }) + .and_then(operation) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RemoteUploadFile => { + match serde_json::from_value(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid upload request") + .with_details(error.to_string()) + }) + .and_then(crate::remote::upload::upload) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RemoteHostInfo => { + match parsed + .payload + .get("sessionId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing host session")) + .and_then(crate::remote::upload::host_info) + { + Ok(value) => CoreResponse::success(id, value), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::RemoteDisconnect => { match serde_json::from_value::( parsed.payload, diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index 7a48f10033..84d72d09d1 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -4255,6 +4255,15 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { .as_bool() .unwrap() ); + // A host-read snapshot uses identical semantics without reopening + // a remote document from a UI-thread selection call. + let snapshot: Value = serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"snapshot", "command":"runConfig.selectJava", "payload":{ + "requirements": document, "candidates": candidates, "fallbackId": case["fallbackId"] + } + }).to_string())).unwrap(); + assert_eq!(snapshot["ok"], true, "{snapshot}"); + assert_eq!(snapshot["data"], response["data"]); } assert_eq!( fs::read_to_string(&requirements).unwrap(), @@ -4280,3 +4289,21 @@ fn automatic_java_selection_uses_shared_fixture_and_current_requirements() { } } } + +#[test] +fn java_selection_rejects_ambiguous_or_invalid_host_snapshots() { + for payload in [ + serde_json::json!({"root":"/unused","requirements":{"version":1,"toolchains":{}},"candidates":[]}), + serde_json::json!({"requirements":{"version":999,"toolchains":{}},"candidates":[]}), + serde_json::json!({"requirements":{"toolchains":{}},"candidates":[]}), + ] { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"snapshot","command":"runConfig.selectJava","payload":payload + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{response}"); + } +} diff --git a/rust/lithe-remote-client/src/api.rs b/rust/lithe-remote-client/src/api.rs index 8c1f0ca5c6..b1e844130e 100644 --- a/rust/lithe-remote-client/src/api.rs +++ b/rust/lithe-remote-client/src/api.rs @@ -216,6 +216,7 @@ impl RemoteSession { &CreateDirParams { path: path.into(), recursive, + mode: None, }, ) } diff --git a/rust/lithe-remote-client/src/session.rs b/rust/lithe-remote-client/src/session.rs index 8c03b794f2..32541a876e 100644 --- a/rust/lithe-remote-client/src/session.rs +++ b/rust/lithe-remote-client/src/session.rs @@ -5,10 +5,10 @@ //! resource (`terminal.output` with `terminalId` 7 goes to the subscription //! for terminal 7) or, without one, to the session's general event channel. //! -//! Every channel is bounded and the reader blocks when one is full, which -//! stops reading the server's stream and so applies backpressure all the way -//! to the remote process. Consumers must therefore drain what they subscribe -//! to, and either drain the general channel or drop its receiver. +//! Every channel is bounded. A stalled resource loses its subscription instead +//! of blocking the shared reader: its owner observes disconnection and reclaims +//! the resource. General-channel overflow ends the connection, since unobserved +//! filesystem events cannot safely be discarded. //! //! A writer thread owns the outgoing stream. Requests queue their frames for //! it, so a peer that stops reading blocks only that thread, and every request @@ -180,20 +180,27 @@ struct Outgoing { } impl Outgoing { - /// Queues a frame, waiting until `deadline` for room. `Ok(false)` means - /// the queue is closed. - fn push(&self, id: u64, payload: Vec, deadline: Instant) -> Result { + /// Returns the still-owned frame when no queue space becomes available + /// during this poll. The caller can observe cancellation between polls. + fn push_until( + &self, + id: u64, + payload: Vec, + deadline: Instant, + ) -> Result>, ClientError> { let mut state = lock(&self.state); loop { if state.closed { - return Ok(false); + return Err(ClientError::Disconnected { + diagnostics: String::new(), + }); } if state.frames.is_empty() || state.bytes + payload.len() <= OUTGOING_QUEUE_BYTES { break; } let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { - return Err(ClientError::Timeout); + return Ok(Some(payload)); } state = self .changed @@ -204,7 +211,7 @@ impl Outgoing { state.bytes += payload.len(); state.frames.push_back((id, payload)); self.changed.notify_all(); - Ok(true) + Ok(None) } /// Queues a frame only if there is room now. Used by the reader thread, @@ -272,6 +279,59 @@ pub struct RemoteSession { hello: Option, } +/// An owned request waiter. Dropping it withdraws unsent work and ignores late +/// replies, without closing the session or its other resources. +pub struct PendingRequest { + id: u64, + reply: Receiver>, + pending: Arc>, + outgoing: Arc, + deadline: Instant, + frame: Mutex>>, +} + +impl PendingRequest { + /// Waits for at most `interval`, returning `None` while the reply is pending. + /// The request's original deadline remains authoritative across polls. + pub fn poll(&self, interval: Duration) -> Result, ClientError> { + let poll_deadline = (Instant::now() + interval).min(self.deadline); + let mut frame = lock(&self.frame); + if let Some(payload) = frame.take() { + *frame = self.outgoing.push_until(self.id, payload, poll_deadline)?; + if frame.is_some() { + return if Instant::now() >= self.deadline { + Err(ClientError::Timeout) + } else { + Ok(None) + }; + } + } + drop(frame); + let remaining = self.deadline.saturating_duration_since(Instant::now()); + match self.reply.recv_timeout( + poll_deadline + .saturating_duration_since(Instant::now()) + .min(remaining), + ) { + Ok(result) => result.map(Some), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(ClientError::Disconnected { + diagnostics: String::new(), + }), + Err(mpsc::RecvTimeoutError::Timeout) if Instant::now() >= self.deadline => { + Err(ClientError::Timeout) + } + Err(mpsc::RecvTimeoutError::Timeout) => Ok(None), + } + } +} + +impl Drop for PendingRequest { + fn drop(&mut self) { + lock(&self.pending).waiting.remove(&self.id); + self.outgoing.withdraw(self.id); + } +} + impl RemoteSession { /// Runs `server_command` at the end of `chain` and completes the handshake. pub fn connect( @@ -463,6 +523,23 @@ impl RemoteSession { params: Value, timeout: Duration, ) -> Result { + let request = self.begin_request_value(method, params, timeout)?; + match request.poll(timeout) { + Ok(Some(value)) => Ok(value), + Err(ClientError::Disconnected { .. }) => Err(self.disconnected()), + Err(error) => Err(error), + Ok(None) => Err(ClientError::Timeout), + } + } + + /// Queues a request without blocking on its reply. Resource owners can drain + /// events and observe cancellation while waiting for creation or control. + pub fn begin_request_value( + &self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { let deadline = Instant::now() + timeout; let id = self.next_request.fetch_add(1, Ordering::Relaxed) + 1; let (reply_sender, reply) = mpsc::sync_channel(1); @@ -478,23 +555,18 @@ impl RemoteSession { method: method.into(), params, }; - if let Err(error) = self.send(id, &message, deadline) { + let payload = serde_json::to_vec(&message).map_err(|error| { lock(&self.pending).waiting.remove(&id); - return Err(error); - } - match reply.recv_timeout(deadline.saturating_duration_since(Instant::now())) { - Ok(Ok(value)) => Ok(value), - Ok(Err(ClientError::Disconnected { .. })) - | Err(mpsc::RecvTimeoutError::Disconnected) => Err(self.disconnected()), - Ok(Err(error)) => Err(error), - Err(mpsc::RecvTimeoutError::Timeout) => { - // Removing the waiter makes the reader drop a late response; - // a frame still queued is not sent at all. - lock(&self.pending).waiting.remove(&id); - self.outgoing.withdraw(id); - Err(ClientError::Timeout) - } - } + ClientError::Protocol(error.to_string()) + })?; + Ok(PendingRequest { + id, + reply, + pending: Arc::clone(&self.pending), + outgoing: Arc::clone(&self.outgoing), + deadline, + frame: Mutex::new(Some(payload)), + }) } /// Whether the stream has ended. @@ -509,15 +581,6 @@ impl RemoteSession { // Drop closes the stream and reaps the transport process. } - fn send(&self, id: u64, message: &Message, deadline: Instant) -> Result<(), ClientError> { - let payload = serde_json::to_vec(message) - .map_err(|error| ClientError::Protocol(error.to_string()))?; - match self.outgoing.push(id, payload, deadline)? { - true => Ok(()), - false => Err(self.disconnected()), - } - } - fn disconnected(&self) -> ClientError { ClientError::Disconnected { diagnostics: self.diagnostics(), @@ -607,7 +670,9 @@ fn read_loop( } } Ok(Message::Event { event, params, seq }) => { - route(&routes, &events, Event { event, params, seq }); + if !route(&routes, &events, Event { event, params, seq }) { + break; + } if let Some(seq) = seq { last_seq.store(seq, Ordering::Release); if seq >= acknowledged + ACK_INTERVAL @@ -653,23 +718,27 @@ fn write_loop(mut writer: Box, outgoing: &Outgoing, pending: & } } -fn route(routes: &Routes, events: &SyncSender, event: Event) { +fn route(routes: &Routes, events: &SyncSender, event: Event) -> bool { let key = event.resource().map(|(family, id)| (family.to_owned(), id)); - // The sender is cloned so that a blocking send does not hold the lock - // a consumer needs to subscribe or unsubscribe. + // Neither a resource nor the general consumer may block the sole response + // reader: a process owner needs that reader to acknowledge its kill request. let subscriber = key.as_ref().and_then(|key| lock(routes).get(key).cloned()); match subscriber { Some(subscriber) => { - if subscriber.send(event).is_err() { - if let Some(key) = key { - lock(routes).remove(&key); + match subscriber.try_send(event) { + Ok(()) => true, + Err(error) => { + if let Some(key) = key { + lock(routes).remove(&key); + } + // A live owner losing a frame must resynchronize. Dropping + // a receiver intentionally only ends that subscription. + matches!(error, mpsc::TrySendError::Disconnected(_)) } } } // A dropped general receiver means nobody consumes these events. - None => { - let _ = events.send(event); - } + None => !matches!(events.try_send(event), Err(mpsc::TrySendError::Full(_))), } } @@ -711,6 +780,73 @@ mod tests { use super::*; use serde_json::json; + fn waiter(outgoing: Arc, payload: Vec) -> (PendingRequest, SyncSender) { + let (sender, reply) = mpsc::sync_channel(1); + let pending = Arc::new(Mutex::new(Pending::default())); + lock(&pending).waiting.insert(1, sender.clone()); + ( + PendingRequest { + id: 1, + reply, + pending, + outgoing, + deadline: Instant::now() + Duration::from_secs(1), + frame: Mutex::new(Some(payload)), + }, + sender, + ) + } + + #[test] + fn dropping_an_unpolled_request_never_sends_it() { + let outgoing = Arc::new(Outgoing::default()); + let (request, sender) = waiter(outgoing.clone(), vec![1, 2]); + let pending = request.pending.clone(); + drop(request); + assert!(lock(&pending).waiting.is_empty()); + assert!(lock(&outgoing.state).frames.is_empty()); + assert!(sender.send(Ok(Value::Null)).is_err()); + } + + #[test] + fn full_writer_queue_remains_cancellable_between_polls() { + let outgoing = Arc::new(Outgoing::default()); + assert!(outgoing.try_push(7, vec![0; OUTGOING_QUEUE_BYTES])); + let (request, _) = waiter(outgoing.clone(), vec![1, 2]); + assert_eq!(request.poll(Duration::ZERO).unwrap(), None); + assert!(lock(&request.frame).is_some()); + drop(request); + assert_eq!(lock(&outgoing.state).frames.len(), 1); + } + + #[test] + fn dropping_a_queued_request_withdraws_only_its_frame() { + let outgoing = Arc::new(Outgoing::default()); + assert!(outgoing.try_push(7, vec![7])); + let (request, _) = waiter(outgoing.clone(), vec![1, 2]); + assert_eq!(request.poll(Duration::ZERO).unwrap(), None); + assert_eq!(lock(&outgoing.state).frames.len(), 2); + drop(request); + let state = lock(&outgoing.state); + assert_eq!(state.frames.len(), 1); + assert_eq!(state.frames[0].0, 7); + assert_eq!(state.bytes, 1); + } + + #[test] + fn polling_delivers_a_reply_without_resetting_the_deadline() { + let outgoing = Arc::new(Outgoing::default()); + let (request, sender) = waiter(outgoing, vec![1]); + let deadline = request.deadline; + assert_eq!(request.poll(Duration::ZERO).unwrap(), None); + sender.send(Ok(json!({"ok": true}))).unwrap(); + assert_eq!( + request.poll(Duration::ZERO).unwrap(), + Some(json!({"ok": true})) + ); + assert_eq!(request.deadline, deadline); + } + #[test] fn events_name_their_resource_by_family_and_id() { let event = Event { @@ -726,4 +862,63 @@ mod tests { }; assert_eq!(unrelated.resource(), None); } + + #[test] + fn full_resource_queue_requires_resynchronization_without_blocking_replies() { + let routes = Mutex::new(HashMap::new()); + let (full, stalled) = mpsc::sync_channel(1); + let (other, healthy) = mpsc::sync_channel(1); + let (general, _) = mpsc::sync_channel(1); + lock(&routes).insert(("process".into(), 7), full); + lock(&routes).insert(("process".into(), 8), other); + let event = |id| Event { + event: "process.output".into(), + params: json!({"processId": id, "data": ""}), + seq: None, + }; + assert!(route(&routes, &general, event(7))); + assert!(!route(&routes, &general, event(7))); + assert!(!lock(&routes).contains_key(&("process".into(), 7))); + assert!(route(&routes, &general, event(8))); + assert_eq!(healthy.try_recv().unwrap().resource(), Some(("process", 8))); + assert!(stalled.try_recv().is_ok()); + assert!(matches!( + stalled.try_recv(), + Err(mpsc::TryRecvError::Disconnected) + )); + } + + #[test] + fn general_overflow_requires_resynchronization_instead_of_losing_watch_changes() { + let routes = Mutex::new(HashMap::new()); + let (sender, receiver) = mpsc::sync_channel(1); + let event = || Event { + event: "watch.changed".into(), + params: json!({}), + seq: None, + }; + assert!(route(&routes, &sender, event())); + assert!(!route(&routes, &sender, event())); + drop(receiver); + assert!(route(&routes, &sender, event())); + } + + #[test] + fn dropped_resource_receiver_does_not_close_the_connection() { + let routes = Mutex::new(HashMap::new()); + let (sender, receiver) = mpsc::sync_channel(1); + let (general, _) = mpsc::sync_channel(1); + lock(&routes).insert(("process".into(), 7), sender); + drop(receiver); + assert!(route( + &routes, + &general, + Event { + event: "process.output".into(), + params: json!({"processId": 7, "data": ""}), + seq: None, + } + )); + assert!(!lock(&routes).contains_key(&("process".into(), 7))); + } } diff --git a/rust/lithe-remote-protocol/src/methods.rs b/rust/lithe-remote-protocol/src/methods.rs index 170a131484..863ccd34a7 100644 --- a/rust/lithe-remote-protocol/src/methods.rs +++ b/rust/lithe-remote-protocol/src/methods.rs @@ -30,9 +30,48 @@ pub const FS_CREATE_DIR: &str = "fs.createDir"; pub const FS_REMOVE: &str = "fs.remove"; pub const FS_RENAME: &str = "fs.rename"; pub const FS_COPY: &str = "fs.copy"; +/// Moves an entry to the host account's recoverable system trash. +pub const FS_TRASH: &str = "fs.trash"; pub const FS_REAL_PATH: &str = "fs.realPath"; pub const FS_SET_PERMISSIONS: &str = "fs.setPermissions"; pub const FS_VOLUME_INFO: &str = "fs.volumeInfo"; +pub const FS_TEMP_DIRECTORY: &str = "fs.tempDirectory"; +/// Connection-owned staging for large files, with verified no-replace publication. +pub const FS_UPLOAD_START: &str = "fs.uploadStart"; +pub const FS_UPLOAD_CHUNK: &str = "fs.uploadChunk"; +pub const FS_UPLOAD_COMMIT: &str = "fs.uploadCommit"; +pub const FS_UPLOAD_ABORT: &str = "fs.uploadAbort"; + +/// Maximum decoded chunk, independent of the full file size. +pub const MAX_UPLOAD_CHUNK_BYTES: usize = 256 * 1024; +/// Maximum staged file, matching the expanded plugin importer budget. +pub const MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +/// A new upload's destination, expected bytes, integrity and final permissions. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UploadStartParams { + pub path: String, + pub size: u64, + pub sha256: String, + pub mode: u32, +} + +/// One strictly ordered chunk of an existing upload. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UploadChunkParams { + pub upload_id: u64, + pub offset: u64, + pub data: String, +} + +/// Identifies the upload being committed or aborted. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UploadParams { + pub upload_id: u64, +} pub const WALK_START: &str = "walk.start"; pub const WALK_CANCEL: &str = "walk.cancel"; @@ -228,6 +267,8 @@ pub enum WatchBackend { pub enum Feature { /// `fs.*` methods. Filesystem, + /// Bounded staged uploads with SHA-256 and atomic no-replace publication. + Upload, /// `walk.*` methods and events. Walk, /// `watch.*` methods and events. @@ -319,6 +360,9 @@ pub struct FileMetadata { /// Kind of the resolved target; `None` for a dangling link. #[serde(default, skip_serializing_if = "Option::is_none")] pub symlink_target_kind: Option, + /// Permissions of the resolved target, never the link's own mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symlink_target_mode: Option, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] @@ -391,6 +435,17 @@ pub struct CreateDirParams { pub path: String, #[serde(default)] pub recursive: bool, + /// POSIX permissions applied during creation, before any content is written. + #[serde(default)] + pub mode: Option, +} + +/// Creates a private disposable directory below the host's temporary root. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TempDirectoryParams { + /// Diagnostic name, restricted to ASCII letters, digits, `_` and `-`. + pub prefix: String, } #[derive(Clone, Debug, Deserialize, Serialize)] diff --git a/rust/lithe-remote-server/Cargo.toml b/rust/lithe-remote-server/Cargo.toml index 47236c27e3..99ae6c0165 100644 --- a/rust/lithe-remote-server/Cargo.toml +++ b/rust/lithe-remote-server/Cargo.toml @@ -14,6 +14,7 @@ lithe-remote-protocol = { path = "../lithe-remote-protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +trash = { version = "5.2.6", default-features = false, features = ["chrono"] } [dev-dependencies] lithe-remote-client = { path = "../lithe-remote-client" } diff --git a/rust/lithe-remote-server/src/connection.rs b/rust/lithe-remote-server/src/connection.rs index 78f37227bf..529e66992e 100644 --- a/rust/lithe-remote-server/src/connection.rs +++ b/rust/lithe-remote-server/src/connection.rs @@ -8,6 +8,7 @@ //! because finding every member of a session scans the process table. use crate::link::{Outbox, Outgoing}; +use crate::resources::{StartCompletion, StartGuard}; use crate::session::Session; use crate::{environment, fs, parse, platform, ports, to_value}; use lithe_remote_protocol::frame::{read_frame, write_frame}; @@ -17,9 +18,11 @@ use lithe_remote_protocol::methods::{ use lithe_remote_protocol::{ErrorCode, Message, RemoteError, PREAMBLE, PROTOCOL_VERSION}; use serde_json::{json, Value}; use std::io::{self, Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::sync::Arc; use std::thread; +use std::time::Duration; /// Messages buffered for the writer thread. A full queue blocks producers; /// see [`crate::link::Link::emit`]. @@ -39,6 +42,7 @@ pub(crate) struct Context { pub(crate) session: Arc, /// Owner of connection-bound resources such as tunnels. pub(crate) connection: u64, + pub(crate) closed: Arc, } pub(crate) fn serve( @@ -57,6 +61,7 @@ pub(crate) fn serve( let context = Context { session: Arc::clone(session), connection: session.next_connection_id(), + closed: Arc::new(AtomicBool::new(false)), }; let mut input = io::BufReader::new(input); let mut handshaken = false; @@ -136,6 +141,7 @@ pub(crate) fn serve( } } + context.closed.store(true, Ordering::Release); session.link.detach(context.connection); session.release_connection_resources(context.connection); let _ = sender.send(Outgoing::Close); @@ -206,7 +212,6 @@ fn is_inline(method: &str) -> bool { | methods::TUNNEL_WRITE | methods::TUNNEL_SHUTDOWN_WRITE | methods::TUNNEL_CLOSE - | methods::WATCH_STOP | methods::WALK_CANCEL ) } @@ -221,6 +226,9 @@ fn handle(context: &Context, method: &str, params: Value) -> Result { + session.uploads.handle(context, method, params) + } method if method.starts_with("fs.") => fs::handle(method, params), method if method.starts_with("walk.") => session.walks.handle(context, method, params), method if method.starts_with("watch.") => session.watches.handle(context, method, params), @@ -241,6 +249,15 @@ fn handle(context: &Context, method: &str, params: Value) -> Result order, + Err(error) => { + outbox.send(Message::failure(id, error)); + return; + } + }; let workers = &context.session.workers; if !workers.try_acquire() { outbox.send(Message::failure( @@ -254,7 +271,17 @@ fn dispatch_to_worker(context: &Context, id: u64, method: String, params: Value, let spawned = thread::Builder::new() .name("remote-worker".into()) .spawn(move || { - let reply = handle(&worker_context, &method, params); + let reply = if matches!(&order, ResourceOrder::After(_)) { + // The creating worker owns teardown, including when this + // request times out or cannot acquire a worker slot. + order.wait(Duration::from_secs(60)).map(|()| Value::Null) + } else { + handle(&worker_context, &method, params) + }; + order.finish(&worker_context); + // Release starts before replying: a blocked writer must not hold + // up cancellation of an already-published resource. + drop(order); worker_outbox.send(respond(id, reply)); worker_context.session.workers.release(); }); @@ -267,6 +294,105 @@ fn dispatch_to_worker(context: &Context, id: u64, method: String, params: Value, } } +enum ResourceOrder { + Start { + guard: StartGuard, + close_method: &'static str, + close_params: Value, + }, + After(Arc), + Independent, +} + +impl ResourceOrder { + fn prepare(session: &Arc, method: &str, params: &Value) -> Result { + let (family, field, start) = match method { + methods::PROCESS_SPAWN => ("process", "processId", true), + methods::PROCESS_KILL => ("process", "processId", false), + methods::TERMINAL_OPEN => ("terminal", "terminalId", true), + methods::TERMINAL_CLOSE => ("terminal", "terminalId", false), + methods::WATCH_START => ("watch", "watchId", true), + methods::WATCH_STOP => ("watch", "watchId", false), + _ => return Ok(Self::Independent), + }; + let Some(id) = params.get(field).and_then(Value::as_u64) else { + return Ok(Self::Independent); // The typed handler reports malformed parameters. + }; + if !start { + match family { + "process" => { + parse::(params.clone())?; + } + "terminal" => { + parse::(params.clone())?; + } + _ => { + parse::(params.clone())?; + } + } + } + if start { + Ok(Self::Start { + guard: session.starts.reserve(family, id)?, + close_method: match family { + "terminal" => methods::TERMINAL_CLOSE, + "watch" => methods::WATCH_STOP, + _ => methods::PROCESS_KILL, + }, + close_params: if family == "process" { + json!({field: id, "force": true}) + } else { + json!({field: id}) + }, + }) + } else { + Ok(session + .starts + .cancel(family, id, params) + .map(Self::After) + .unwrap_or(Self::Independent)) + } + } + + fn wait(&self, timeout: Duration) -> Result<(), RemoteError> { + match self { + Self::After(completion) => completion.wait(timeout), + _ => Ok(()), + } + } + + fn finish(&self, context: &Context) { + if let Self::Start { + guard, + close_method, + close_params, + } = self + { + let requested = guard.take_cancellation(); + let cancellation = if context.session.is_ending() + || (!context.session.persistent && context.closed.load(Ordering::Acquire)) + { + Some(close_params.clone()) + } else { + requested + }; + if let Some(params) = cancellation { + if let Err(error) = handle(context, close_method, params) { + if error.code != ErrorCode::UnknownResource { + eprintln!( + "lithe-remote-server: cancelled start cleanup failed: {:?}", + error.code + ); + } + } + } + } + } +} + +#[cfg(test)] +mod tests; + fn write_loop(mut output: impl Write, receiver: Receiver) -> io::Result<()> { while let Ok(Outgoing::Message(message)) = receiver.recv() { let payload = serde_json::to_vec(&message).map_err(io::Error::from)?; diff --git a/rust/lithe-remote-server/src/connection/tests.rs b/rust/lithe-remote-server/src/connection/tests.rs new file mode 100644 index 0000000000..6f6f703393 --- /dev/null +++ b/rust/lithe-remote-server/src/connection/tests.rs @@ -0,0 +1,281 @@ +//! Deterministic scheduling regressions for creation followed by cancellation. + +use super::*; +use std::sync::mpsc; +use std::time::Instant; + +const STEP: Duration = Duration::from_secs(5); + +#[test] +fn cancellation_waits_for_process_terminal_and_watch_publication() { + let root = WatchRoot::new(); + for (start, stop, params, close) in [ + ( + methods::PROCESS_SPAWN, + methods::PROCESS_KILL, + json!({"processId": 1, "argv": ["/bin/cat"], "pipeStdin": true}), + json!({"processId": 1, "force": true}), + ), + ( + methods::TERMINAL_OPEN, + methods::TERMINAL_CLOSE, + json!({"terminalId": 1, "argv": ["/bin/cat"], "columns": 80, "rows": 24}), + json!({"terminalId": 1}), + ), + ( + methods::WATCH_START, + methods::WATCH_STOP, + json!({"watchId": 1, "root": root.0}), + json!({"watchId": 1}), + ), + ] { + let session = Session::new(false).unwrap(); + let cleanup = Cleanup(Arc::clone(&session)); + let creation = ResourceOrder::prepare(&session, start, ¶ms).unwrap(); + let cancellation = ResourceOrder::prepare(&session, stop, &close).unwrap(); + // The close worker runs first, while the creation worker is not yet + // scheduled. It must not reach the still-empty resource table. + assert!(cancellation.wait(Duration::ZERO).is_err()); + assert!(ResourceOrder::prepare(&session, start, ¶ms).is_err()); + let (done, completed) = mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = cancellation.wait(STEP); + let _ = done.send(result); + }); + let result = handle( + &Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::default(), + }, + start, + params, + ); + creation.finish(&Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::default(), + }); + drop(creation); + let outcome = completed + .recv_timeout(STEP) + .expect("close worker must finish"); + worker.join().unwrap(); + result.unwrap(); + outcome.unwrap(); + // Monitor threads report leader exit asynchronously. Observe the + // public state with a local deadline, never synchronize with sleeps. + let deadline = Instant::now() + STEP; + loop { + let state = session.state(); + if state.processes.is_empty() && state.terminals.is_empty() && state.watches.is_empty() + { + break; + } + assert!( + Instant::now() < deadline, + "cancelled resource remained active: {state:?}" + ); + std::thread::yield_now(); + } + drop(cleanup); + } +} + +#[test] +fn failed_or_unscheduled_start_releases_only_its_own_waiters() { + let session = Session::new(false).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"processId": 7, "force": true}); + let creation = ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); + let cancellation = ResourceOrder::prepare(&session, methods::PROCESS_KILL, ¶ms).unwrap(); + assert!(cancellation.wait(Duration::ZERO).is_err()); + // A different id and resource family remain independently schedulable. + ResourceOrder::prepare(&session, methods::PROCESS_KILL, &json!({"processId": 8})) + .unwrap() + .wait(Duration::ZERO) + .unwrap(); + ResourceOrder::prepare(&session, methods::TERMINAL_CLOSE, &json!({"terminalId": 7})) + .unwrap() + .wait(Duration::ZERO) + .unwrap(); + drop(creation); + cancellation.wait(Duration::ZERO).unwrap(); + ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); +} + +#[test] +fn cancellation_remains_owned_when_the_close_worker_is_rejected() { + let session = Session::new(false).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"processId": 11, "argv": ["/bin/cat"], "pipeStdin": true}); + let creation = ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); + let context = Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::default(), + }; + let mut slots = 0; + while session.workers.try_acquire() { + slots += 1; + } + let (send, receive) = mpsc::sync_channel(1); + dispatch_to_worker( + &context, + 99, + methods::PROCESS_KILL.into(), + json!({"processId": 11, "force": true}), + &Outbox(send), + ); + let crate::link::Outgoing::Message(message) = receive.recv_timeout(STEP).unwrap() else { + panic!("expected reply"); + }; + assert_eq!( + serde_json::to_value(message).unwrap()["error"]["code"], + "BUSY" + ); + for _ in 0..slots { + session.workers.release(); + } + handle(&context, methods::PROCESS_SPAWN, params).unwrap(); + creation.finish(&context); + drop(creation); + let deadline = Instant::now() + STEP; + while !session.state().processes.is_empty() { + assert!( + Instant::now() < deadline, + "rejected close leaked its pending child" + ); + std::thread::yield_now(); + } +} + +#[test] +fn malformed_close_does_not_cancel_a_pending_creation() { + let session = Session::new(false).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"processId": 3}); + let creation = ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); + assert!(ResourceOrder::prepare( + &session, + methods::PROCESS_KILL, + &json!({"processId": 3, "force": "invalid"}) + ) + .is_err()); + let ResourceOrder::Start { guard, .. } = &creation else { + panic!("expected start"); + }; + assert!(guard.take_cancellation().is_none()); +} + +#[test] +fn forced_cancellation_and_a_new_reservation_survive_old_guard_release() { + let session = Session::new(false).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"processId": 17}); + let creation = ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); + let forced = ResourceOrder::prepare( + &session, + methods::PROCESS_KILL, + &json!({"processId": 17, "force": true}), + ) + .unwrap(); + let ordinary = ResourceOrder::prepare(&session, methods::PROCESS_KILL, ¶ms).unwrap(); + let ResourceOrder::Start { guard, .. } = &creation else { + panic!("expected start"); + }; + assert_eq!( + guard.take_cancellation(), + Some(json!({"processId": 17, "force": true})) + ); + // Ownership transfers before the old guard drops. Its completion must + // not remove a newer reservation for the same key. + let replacement = ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); + drop(creation); + forced.wait(Duration::ZERO).unwrap(); + ordinary.wait(Duration::ZERO).unwrap(); + assert!(ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).is_err()); + drop(replacement); + ResourceOrder::prepare(&session, methods::PROCESS_SPAWN, ¶ms).unwrap(); +} + +#[test] +fn disconnect_reclaims_late_publication_only_for_nonpersistent_sessions() { + let root = WatchRoot::new(); + for persistent in [false, true] { + let session = Session::new(persistent).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"watchId": 21, "root": root.0}); + let creation = ResourceOrder::prepare(&session, methods::WATCH_START, ¶ms).unwrap(); + let context = Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::new(AtomicBool::new(true)), + }; + // The reader has disconnected before the creation worker publishes. + // Persistent sessions retain resources for a later reconnection. + handle(&context, methods::WATCH_START, params).unwrap(); + creation.finish(&context); + drop(creation); + assert_eq!(session.state().watches.len(), usize::from(persistent)); + } +} + +#[test] +fn shutdown_reclaims_late_publication_in_persistent_sessions() { + let root = WatchRoot::new(); + for explicit_shutdown in [false, true] { + let session = Session::new(true).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"watchId": 23, "root": root.0}); + let creation = ResourceOrder::prepare(&session, methods::WATCH_START, ¶ms).unwrap(); + // Explicit shutdown and idle expiry may both drain an empty table + // before a previously scheduled creation has published its resource. + if explicit_shutdown { + session.begin_shutdown(); + } else { + session.terminate_all(); + } + let context = Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::default(), + }; + handle(&context, methods::WATCH_START, params).unwrap(); + creation.finish(&context); + drop(creation); + assert!(session.state().watches.is_empty()); + } +} + +/// Watch an owned empty directory, never the user's shared temporary tree. +struct WatchRoot(String); + +impl WatchRoot { + fn new() -> Self { + let result: methods::RealPathResult = serde_json::from_value( + fs::handle( + methods::FS_TEMP_DIRECTORY, + json!({"prefix": "lithe-remote-lifecycle"}), + ) + .unwrap(), + ) + .unwrap(); + Self(result.path) + } +} + +impl Drop for WatchRoot { + fn drop(&mut self) { + if let Err(error) = std::fs::remove_dir(&self.0) { + eprintln!("failed to remove test watch root: {error}"); + } + } +} + +struct Cleanup(Arc); +impl Drop for Cleanup { + fn drop(&mut self) { + self.0.terminate_all(); + } +} diff --git a/rust/lithe-remote-server/src/daemon.rs b/rust/lithe-remote-server/src/daemon.rs index 7a01de79a5..c448f42e91 100644 --- a/rust/lithe-remote-server/src/daemon.rs +++ b/rust/lithe-remote-server/src/daemon.rs @@ -15,6 +15,7 @@ use crate::connection::{self, Ended}; use crate::platform; use crate::session::Session; +use sha2::{Digest, Sha256}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; use std::os::fd::AsRawFd; @@ -91,10 +92,10 @@ impl PersistentOptions { ) .join(".lithe-server"), }; - // The server version is part of the name: a client upgrade starts a - // new daemon instead of talking to an older one, which ends on its - // own once idle. - let name = format!("{}-{}", self.key, env!("CARGO_PKG_VERSION")); + // Preview builds can change protocol features without changing version. + // Hash both identities to keep maximal session keys within socket limits. + let identity = crate::identity()?; + let name = daemon_name(&self.key, &identity.sha256); let run = state.join("run"); private_directory(&run)?; let mut socket = run.join(format!("{name}.sock")); @@ -122,6 +123,14 @@ impl PersistentOptions { } } +fn daemon_name(key: &str, executable_sha256: &str) -> String { + let mut digest = Sha256::new(); + digest.update(key.as_bytes()); + digest.update([0]); + digest.update(executable_sha256.as_bytes()); + format!("{:x}", digest.finalize())[..32].to_owned() +} + /// Creates `path` with mode 0700, or verifies an existing one is a real /// directory owned by the user that nobody else can write to. fn private_directory(path: &Path) -> io::Result<()> { @@ -397,3 +406,22 @@ fn write_pid(mut lock: &File) -> io::Result<()> { writeln!(lock, "{}", std::process::id())?; lock.flush() } + +#[cfg(test)] +mod identity_tests { + use super::*; + + #[test] + fn same_version_binary_rebuilds_cannot_reuse_a_daemon() { + let key = "a".repeat(64); + let old = daemon_name(&key, &"1".repeat(64)); + assert_eq!(old.len(), 32); + assert_eq!(old, daemon_name(&key, &"1".repeat(64))); + assert_ne!(old, daemon_name(&key, &"2".repeat(64))); + assert_ne!(old, daemon_name("other-session", &"1".repeat(64))); + let fallback = PathBuf::from(SHORT_SOCKET_ROOT) + .join(format!("lithe-server-{}", u32::MAX)) + .join(format!("{old}.sock")); + assert!(fallback.as_os_str().len() <= MAX_SOCKET_PATH_BYTES); + } +} diff --git a/rust/lithe-remote-server/src/fs.rs b/rust/lithe-remote-server/src/fs.rs index 82e67d9499..d1228cd95d 100644 --- a/rust/lithe-remote-server/src/fs.rs +++ b/rust/lithe-remote-server/src/fs.rs @@ -32,9 +32,19 @@ pub(crate) fn handle(method: &str, params: Value) -> Result methods::FS_READ_FILE => to_value(read_file(parse(params)?)?), methods::FS_WRITE_FILE => to_value(write_file(parse(params)?)?), methods::FS_CREATE_DIR => create_dir(parse(params)?).map(|()| Value::Null), + methods::FS_TEMP_DIRECTORY => to_value(temp_directory(parse(params)?)?), methods::FS_REMOVE => remove(parse(params)?).map(|()| Value::Null), methods::FS_RENAME => rename(parse(params)?).map(|()| Value::Null), methods::FS_COPY => to_value(copy(parse(params)?)?), + methods::FS_TRASH => { + let params: PathParams = parse(params)?; + let path = host_path(¶ms.path)?; + trash::delete(path).map_err(|error| { + RemoteError::new(ErrorCode::Io, "could not move the item to the host trash") + .with_details(json!({"reason": error.to_string()})) + })?; + Ok(Value::Null) + } methods::FS_REAL_PATH => { let params: PathParams = parse(params)?; let resolved = fs::canonicalize(host_path(¶ms.path)?) @@ -119,6 +129,9 @@ fn describe(path: &Path, metadata: &fs::Metadata) -> FileMetadata { mode: metadata.mode() & 0o7777, symlink_target, symlink_target_kind, + symlink_target_mode: (kind == FileKind::Symlink) + .then(|| fs::metadata(path).ok().map(|target| target.mode() & 0o7777)) + .flatten(), } } @@ -306,14 +319,59 @@ fn create_temporary(path: &Path, replaced: Option<&fs::Metadata>) -> io::Result< fn create_dir(params: CreateDirParams) -> Result<(), RemoteError> { let path = host_path(¶ms.path)?; - let created = if params.recursive { - fs::create_dir_all(&path) - } else { - fs::create_dir(&path) - }; + let mut builder = fs::DirBuilder::new(); + builder.recursive(params.recursive); + if let Some(mode) = params.mode { + if mode > 0o7777 { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid directory mode", + )); + } + use std::os::unix::fs::DirBuilderExt; + builder.mode(mode); + } + let created = builder.create(&path); created.map_err(|error| io_error(error, ¶ms.path)) } +fn temp_directory(params: methods::TempDirectoryParams) -> Result { + if params.prefix.is_empty() + || params.prefix.len() > 64 + || !params + .prefix + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid temporary directory prefix", + )); + } + for _ in 0..128 { + let sequence = TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "{}-{}-{sequence}", + params.prefix, + std::process::id() + )); + use std::os::unix::fs::DirBuilderExt; + match fs::DirBuilder::new().mode(0o700).create(&path) { + Ok(()) => { + return Ok(RealPathResult { + path: path.to_string_lossy().into_owned(), + }) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(io_error(error, &path.to_string_lossy())), + } + } + Err(RemoteError::new( + ErrorCode::Io, + "could not allocate a private temporary directory", + )) +} + fn remove(params: RemoveParams) -> Result<(), RemoteError> { let path = host_path(¶ms.path)?; let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(error, ¶ms.path))?; @@ -390,6 +448,32 @@ fn copy(params: CopyParams) -> Result { _ => {} } let mut result = CopyResult::default(); + if !params.overwrite { + // Publish a complete copy with the same no-replace primitive as rename. + // An existence check alone can race another client and truncate its file. + let parent = to + .parent() + .ok_or_else(|| RemoteError::new(ErrorCode::InvalidPath, "cannot copy onto the root"))?; + let staging = parent.join(format!( + ".lithe-copy-{}-{}", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .mode(0o700) + .create(&staging) + .map_err(|error| io_error(error, ¶ms.to))?; + let copied = staging.join("content"); + let outcome = copy_entry(&from, &copied, &source, &mut result) + .and_then(|()| platform::rename_no_replace(&copied, &to)); + // This directory is owned solely by this request, never a user target. + if let Err(error) = fs::remove_dir_all(&staging) { + eprintln!("remote copy staging cleanup failed: {:?}", error.kind()); + } + outcome.map_err(|error| io_error(error, ¶ms.to))?; + return Ok(result); + } copy_entry(&from, &to, &source, &mut result).map_err(|error| io_error(error, ¶ms.from))?; Ok(result) } @@ -516,6 +600,30 @@ pub(crate) fn io_error(error: io::Error, path: &str) -> RemoteError { mod tests { use super::*; + #[test] + fn link_metadata_reports_target_permissions_not_symlink_permissions() { + let directory = std::env::temp_dir().join(format!( + "lithe-remote-link-mode-{}-{}", + std::process::id(), + TEMPORARY_FILE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&directory).unwrap(); + let _cleanup = RemoveOnDrop(directory.clone()); + let target = directory.join("target"); + let link = directory.join("link"); + fs::write(&target, b"not executable").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let metadata = describe(&link, &fs::symlink_metadata(&link).unwrap()); + assert_eq!(metadata.symlink_target_kind, Some(FileKind::File)); + assert_eq!(metadata.symlink_target_mode, Some(0o600)); + fs::remove_file(&target).unwrap(); + assert_eq!( + describe(&link, &fs::symlink_metadata(&link).unwrap()).symlink_target_mode, + None + ); + } + // Other users must not be able to open the temporary file while it holds // the new content of an owner-only file; tightening the mode afterwards // would not revoke a descriptor they already opened. diff --git a/rust/lithe-remote-server/src/lib.rs b/rust/lithe-remote-server/src/lib.rs index 1223e28d7f..46a8a8134f 100644 --- a/rust/lithe-remote-server/src/lib.rs +++ b/rust/lithe-remote-server/src/lib.rs @@ -33,6 +33,7 @@ mod session; mod spawner; mod terminal; mod tunnel; +mod upload; mod walk; mod watch; diff --git a/rust/lithe-remote-server/src/platform/mod.rs b/rust/lithe-remote-server/src/platform/mod.rs index be4f42cc2d..7b07e51154 100644 --- a/rust/lithe-remote-server/src/platform/mod.rs +++ b/rust/lithe-remote-server/src/platform/mod.rs @@ -51,6 +51,7 @@ pub(crate) fn host_capabilities() -> HostCapabilities { let account = unix::current_account(); let mut features = vec![ Feature::Filesystem, + Feature::Upload, Feature::Walk, Feature::Watch, Feature::Process, diff --git a/rust/lithe-remote-server/src/resources.rs b/rust/lithe-remote-server/src/resources.rs index db6369551c..d1d3dd914b 100644 --- a/rust/lithe-remote-server/src/resources.rs +++ b/rust/lithe-remote-server/src/resources.rs @@ -1,9 +1,10 @@ //! Shared bookkeeping for client-identified resources. use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::Value; use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Condvar, Mutex, MutexGuard}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::{Duration, Instant}; /// Highest resource id any client used in this session, so a reconnecting or @@ -21,6 +22,117 @@ impl IdWatermark { } } +/// Starts are registered by the connection reader, before worker scheduling. +/// A later close must wait for publication (or failure), not race an empty table. +#[derive(Default)] +pub(crate) struct PendingStarts { + entries: Mutex>>, +} + +#[derive(Default)] +pub(crate) struct StartCompletion { + finished: Mutex, + changed: Condvar, + close_params: Mutex>, +} + +impl StartCompletion { + pub(crate) fn wait(&self, timeout: Duration) -> Result<(), RemoteError> { + let deadline = Instant::now() + timeout; + let mut finished = crate::lock(&self.finished); + while !*finished { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(RemoteError::new( + ErrorCode::Busy, + "resource is still starting", + )); + } + finished = self + .changed + .wait_timeout(finished, remaining) + .unwrap_or_else(|error| error.into_inner()) + .0; + } + Ok(()) + } +} + +impl PendingStarts { + pub(crate) fn reserve( + self: &Arc, + family: &'static str, + id: u64, + ) -> Result { + let mut entries = crate::lock(&self.entries); + let key = (family, id); + if entries.contains_key(&key) { + return Err(RemoteError::new( + ErrorCode::ResourceIdInUse, + "resource is already starting", + )); + } + let completion = Arc::new(StartCompletion::default()); + entries.insert(key, Arc::clone(&completion)); + Ok(StartGuard { + starts: Arc::clone(self), + key, + completion, + }) + } + + pub(crate) fn cancel( + &self, + family: &'static str, + id: u64, + params: &Value, + ) -> Option> { + let entries = crate::lock(&self.entries); + let completion = entries.get(&(family, id))?; + let mut close = crate::lock(&completion.close_params); + // Multiple cancellation attempts may strengthen, but never downgrade, + // an explicit force kill while the child is still being created. + if close + .as_ref() + .is_none_or(|value| value.get("force") != Some(&Value::Bool(true))) + { + *close = Some(params.clone()); + } + Some(Arc::clone(completion)) + } +} + +/// Dropping the worker's reservation releases waiters even after spawn failure, +/// worker scheduling failure, or panic. No lifecycle lock covers host I/O. +pub(crate) struct StartGuard { + starts: Arc, + key: (&'static str, u64), + completion: Arc, +} + +impl StartGuard { + /// After publication, atomically transfers close ownership either to the + /// creating worker or to a subsequent ordinary close request. + pub(crate) fn take_cancellation(&self) -> Option { + crate::lock(&self.starts.entries).remove(&self.key); + crate::lock(&self.completion.close_params).take() + } +} + +impl Drop for StartGuard { + fn drop(&mut self) { + let mut entries = crate::lock(&self.starts.entries); + *crate::lock(&self.completion.finished) = true; + if entries + .get(&self.key) + .is_some_and(|entry| Arc::ptr_eq(entry, &self.completion)) + { + entries.remove(&self.key); + } + self.completion.changed.notify_all(); + } +} + /// Live resources of one family, keyed by the client's id. The condition /// variable lets shutdown wait, with a deadline, until resources have ended. pub(crate) struct Registry { diff --git a/rust/lithe-remote-server/src/session.rs b/rust/lithe-remote-server/src/session.rs index bf2c8a5ea6..b1a4cd5ef3 100644 --- a/rust/lithe-remote-server/src/session.rs +++ b/rust/lithe-remote-server/src/session.rs @@ -3,7 +3,7 @@ use crate::environment::EnvironmentCache; use crate::link::Link; use crate::process::ProcessTable; -use crate::resources::{IdWatermark, InFlight}; +use crate::resources::{IdWatermark, InFlight, PendingStarts}; use crate::spawner::Spawner; use crate::terminal::TerminalTable; use crate::tunnel::TunnelTable; @@ -46,6 +46,7 @@ fn start_reaper(session: std::sync::Weak) -> std::io::Result<()> { }; session.processes.reap(); session.terminals.reap(); + session.uploads.expire(); }) .map(|_| ()) } @@ -61,8 +62,13 @@ pub(crate) struct Session { pub(crate) watches: WatchTable, pub(crate) tunnels: TunnelTable, pub(crate) walks: WalkTable, + pub(crate) uploads: crate::upload::UploadTable, /// Requests running on worker threads, drained before the session ends. pub(crate) workers: InFlight, + pub(crate) starts: Arc, + /// Late publications must be reclaimed after shutdown starts, including + /// persistent sessions whose disconnected resources normally survive. + ending: AtomicBool, /// Whether any client completed a handshake before, which makes the /// next one a resumption. had_client: AtomicBool, @@ -86,7 +92,10 @@ impl Session { watches: WatchTable::new(), tunnels: TunnelTable::new(), walks: WalkTable::new(), + uploads: crate::upload::UploadTable::default(), workers: InFlight::default(), + starts: Arc::default(), + ending: AtomicBool::new(false), had_client: AtomicBool::new(false), next_connection: AtomicU64::new(0), idle_timeout: Mutex::new(DEFAULT_IDLE_TIMEOUT), @@ -102,11 +111,16 @@ impl Session { /// Called for `server.shutdown` before it is answered. pub(crate) fn begin_shutdown(&self) { + self.ending.store(true, Ordering::Release); if let Some(hook) = crate::lock(&self.shutdown_hook).take() { hook(); } } + pub(crate) fn is_ending(&self) -> bool { + self.ending.load(Ordering::Acquire) + } + pub(crate) fn next_connection_id(&self) -> u64 { self.next_connection.fetch_add(1, Ordering::Relaxed) + 1 } @@ -137,12 +151,14 @@ impl Session { /// local sockets are gone; walks end because their results would only /// fill the replay buffer. pub(crate) fn release_connection_resources(&self, connection: u64) { + self.uploads.release(connection); self.tunnels.close_owned_by(connection); self.walks.cancel_owned_by(connection); } /// Ends everything the session started. pub(crate) fn terminate_all(&self) { + self.ending.store(true, Ordering::Release); self.tunnels.close_all(); self.walks.cancel_all(); self.watches.stop_all(); @@ -154,5 +170,6 @@ impl Session { if unfinished > 0 { eprintln!("lithe-remote-server: {unfinished} requests did not finish"); } + self.uploads.clear(); } } diff --git a/rust/lithe-remote-server/src/upload.rs b/rust/lithe-remote-server/src/upload.rs new file mode 100644 index 0000000000..acaa2c64c5 --- /dev/null +++ b/rust/lithe-remote-server/src/upload.rs @@ -0,0 +1,203 @@ +//! Connection-owned, bounded uploads with verified atomic publication. +//! Staging files disappear on abort, disconnect, expiry, or failed commit. + +use crate::connection::Context; +use crate::fs::{host_path, io_error}; +use crate::{lock, parse, platform}; +use lithe_remote_protocol::methods::{self, UploadChunkParams, UploadParams, UploadStartParams}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const MAX_UPLOADS: usize = 4; +const LIFETIME: Duration = Duration::from_secs(5 * 60); +static SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Default)] +pub(crate) struct UploadTable(Mutex>); + +/// Owns one private staging inode until publication or cleanup. +struct Upload { + owner: u64, + file: File, + staging: PathBuf, + target: PathBuf, + size: u64, + received: u64, + expected: String, + mode: u32, + hash: Sha256, + expires: Instant, +} + +impl Drop for Upload { + fn drop(&mut self) { + if let Err(error) = fs::remove_file(&self.staging) { + if error.kind() != std::io::ErrorKind::NotFound { + eprintln!("remote upload cleanup failed: {:?}", error.kind()); + } + } + } +} + +impl UploadTable { + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + let mut uploads = lock(&self.0); + if context.closed.load(Ordering::Acquire) { + return Err(RemoteError::new( + ErrorCode::NotFound, + "upload connection closed", + )); + } + uploads.retain(|_, upload| upload.expires > Instant::now()); + if method == methods::FS_UPLOAD_START { + let params: UploadStartParams = parse(params)?; + if uploads.len() >= MAX_UPLOADS { + return Err(RemoteError::new(ErrorCode::Busy, "too many staged uploads")); + } + if params.size > methods::MAX_UPLOAD_BYTES + || params.mode > 0o777 + || params.sha256.len() != 64 + || !params.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid upload size, digest or mode", + )); + } + let target = host_path(¶ms.path)?; + let parent = target.parent().ok_or_else(|| { + RemoteError::new(ErrorCode::InvalidPath, "cannot upload to the root") + })?; + let id = SEQUENCE.fetch_add(1, Ordering::Relaxed); + let staging = parent.join(format!(".lithe-upload-{}-{id}", std::process::id())); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staging) + .map_err(|error| io_error(error, ¶ms.path))?; + uploads.insert( + id, + Upload { + owner: context.connection, + file, + staging, + target, + size: params.size, + received: 0, + expected: params.sha256.to_ascii_lowercase(), + mode: params.mode, + hash: Sha256::new(), + expires: Instant::now() + LIFETIME, + }, + ); + return Ok(json!({"uploadId": id})); + } + let id = params + .get("uploadId") + .and_then(Value::as_u64) + .ok_or_else(|| RemoteError::new(ErrorCode::InvalidRequest, "missing uploadId"))?; + if uploads + .get(&id) + .is_none_or(|upload| upload.owner != context.connection) + { + return Err(RemoteError::new( + ErrorCode::NotFound, + "staged upload not found", + )); + } + match method { + methods::FS_UPLOAD_CHUNK => { + let params: UploadChunkParams = parse(params)?; + let upload = uploads.get_mut(&id).unwrap(); + if params.data.len() > methods::MAX_UPLOAD_CHUNK_BYTES.div_ceil(3) * 4 { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "upload chunk too large", + )); + } + let bytes = methods::decode_bytes(¶ms.data).map_err(|_| { + RemoteError::new(ErrorCode::InvalidRequest, "invalid upload chunk") + })?; + if bytes.is_empty() || bytes.len() > methods::MAX_UPLOAD_CHUNK_BYTES { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "upload chunk size is invalid", + )); + } + if params.offset != upload.received + || upload.received + bytes.len() as u64 > upload.size + { + return Err(RemoteError::new( + ErrorCode::Conflict, + "upload offset or size mismatch", + )); + } + if let Err(error) = upload.file.write_all(&bytes) { + uploads.remove(&id); + return Err(io_error(error, "staged upload")); + } + upload.hash.update(&bytes); + upload.received += bytes.len() as u64; + Ok(json!({"offset": upload.received})) + } + methods::FS_UPLOAD_ABORT => { + let _: UploadParams = parse(params)?; + uploads.remove(&id); + Ok(Value::Null) + } + methods::FS_UPLOAD_COMMIT => { + let _: UploadParams = parse(params)?; + let upload = uploads.remove(&id).unwrap(); + let digest = upload + .hash + .clone() + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + if upload.received != upload.size || digest != upload.expected { + return Err(RemoteError::new( + ErrorCode::Conflict, + "upload integrity check failed", + )); + } + upload + .file + .set_permissions(fs::Permissions::from_mode(upload.mode)) + .and_then(|()| upload.file.sync_all()) + .and_then(|()| platform::rename_no_replace(&upload.staging, &upload.target)) + .map_err(|error| io_error(error, "staged upload"))?; + Ok(json!({"size": upload.size, "sha256": digest})) + } + _ => Err(RemoteError::new( + ErrorCode::UnknownMethod, + "unknown upload operation", + )), + } + } + + pub(crate) fn release(&self, owner: u64) { + lock(&self.0).retain(|_, upload| upload.owner != owner); + } + pub(crate) fn expire(&self) { + lock(&self.0).retain(|_, upload| upload.expires > Instant::now()); + } + pub(crate) fn clear(&self) { + lock(&self.0).clear(); + } +} diff --git a/rust/lithe-remote-server/tests/server.rs b/rust/lithe-remote-server/tests/server.rs index a575632166..f81ad1f400 100644 --- a/rust/lithe-remote-server/tests/server.rs +++ b/rust/lithe-remote-server/tests/server.rs @@ -14,6 +14,7 @@ use lithe_remote_protocol::methods::{ }; use lithe_remote_protocol::{ErrorCode, Message}; use serde_json::json; +use sha2::{Digest, Sha256}; use std::io::BufReader; use std::process::{Command, Stdio}; use support::{connect, connect_command, options, TempDir, SERVER, STEP_TIMEOUT}; @@ -25,6 +26,221 @@ fn code(result: Result) -> ErrorCode { } } +#[test] +fn staged_upload_checks_order_digest_and_atomic_no_replace_publication() { + let root = TempDir::new("staged-upload"); + let (session, _events) = connect(); + let request = |method: &str, params| session.request_value(method, params, STEP_TIMEOUT); + let content = vec![b'p'; methods::MAX_UPLOAD_CHUNK_BYTES + 7]; + let digest = format!("{:x}", Sha256::digest(&content)); + let start = |name: &str, digest: &str| { + request(methods::FS_UPLOAD_START, + json!({"path": root.join(name), "size": content.len(), "sha256": digest, "mode": 0o755})).unwrap()["uploadId"].as_u64().unwrap() + }; + let id = start("package", &digest); + assert_eq!( + code(request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": 0, + "data": encode_bytes(&vec![b'x'; methods::MAX_UPLOAD_CHUNK_BYTES + 1])}) + )), + ErrorCode::InvalidRequest + ); + assert_eq!( + code(request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": 1, "data": encode_bytes(b"x")}) + )), + ErrorCode::Conflict + ); + for (index, chunk) in content.chunks(methods::MAX_UPLOAD_CHUNK_BYTES).enumerate() { + let offset = index * methods::MAX_UPLOAD_CHUNK_BYTES; + let received = request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": id, "offset": offset, "data": encode_bytes(chunk)}), + ) + .unwrap(); + assert_eq!(received["offset"], offset + chunk.len()); + assert!( + !std::path::Path::new(&root.join("package")).exists(), + "uncommitted bytes stay private" + ); + } + let result = request(methods::FS_UPLOAD_COMMIT, json!({"uploadId": id})).unwrap(); + assert_eq!(result["sha256"], digest); + assert_eq!(std::fs::read(root.join("package")).unwrap(), content); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(root.join("package")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o755 + ); + let bad = start("bad-digest", &"0".repeat(64)); + for (index, chunk) in content.chunks(methods::MAX_UPLOAD_CHUNK_BYTES).enumerate() { + request( + methods::FS_UPLOAD_CHUNK, + json!({"uploadId": bad, "offset": index * methods::MAX_UPLOAD_CHUNK_BYTES, + "data": encode_bytes(chunk)}), + ) + .unwrap(); + } + assert_eq!( + code(request(methods::FS_UPLOAD_COMMIT, json!({"uploadId": bad}))), + ErrorCode::Conflict + ); + assert!(!std::path::Path::new(&root.join("bad-digest")).exists()); + let collision = start("package", &digest); + for (index, chunk) in content.chunks(methods::MAX_UPLOAD_CHUNK_BYTES).enumerate() { + request(methods::FS_UPLOAD_CHUNK, json!({"uploadId": collision, "offset": index * methods::MAX_UPLOAD_CHUNK_BYTES, "data": encode_bytes(chunk)})).unwrap(); + } + assert_eq!( + code(request( + methods::FS_UPLOAD_COMMIT, + json!({"uploadId": collision}) + )), + ErrorCode::AlreadyExists + ); + let aborted = start("aborted", &digest); + request(methods::FS_UPLOAD_ABORT, json!({"uploadId": aborted})).unwrap(); + assert_eq!( + std::fs::read_dir(root.path()).unwrap().count(), + 1, + "all staging inodes are removed" + ); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn staged_upload_disconnect_drops_unpublished_files_and_connection_ownership() { + let root = TempDir::new("upload-disconnect"); + let daemon = support::Daemon::new("upload-disconnect"); + let (session, _events) = daemon.connect(None); + let id = session.request_value(methods::FS_UPLOAD_START, + json!({"path": root.join("package"), "size": 1, "sha256": "0".repeat(64), "mode": 0o600}), STEP_TIMEOUT).unwrap()["uploadId"].as_u64().unwrap(); + drop(session); + let (replacement, _) = daemon.connect(None); + assert_eq!( + code(replacement.request_value( + methods::FS_UPLOAD_COMMIT, + json!({"uploadId": id}), + STEP_TIMEOUT + )), + ErrorCode::NotFound + ); + replacement.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped(), "upload server did not stop"); + assert!(std::fs::read_dir(root.path()).unwrap().next().is_none()); +} + +#[test] +fn staged_upload_limit_and_shutdown_release_private_files() { + let root = TempDir::new("upload-cleanup"); + let daemon = support::Daemon::new("upload-cleanup"); + let (session, _events) = daemon.connect(None); + for index in 0..4 { + session.request_value(methods::FS_UPLOAD_START, + json!({"path": root.join(&format!("file-{index}")), "size": 1, "sha256": "0".repeat(64), "mode": 0o600}), STEP_TIMEOUT).unwrap(); + } + assert_eq!(code(session.request_value(methods::FS_UPLOAD_START, + json!({"path": root.join("overflow"), "size": 1, "sha256": "0".repeat(64), "mode": 0o600}), STEP_TIMEOUT)), ErrorCode::Busy); + session.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped(), "upload server did not stop"); + assert!(std::fs::read_dir(root.path()).unwrap().next().is_none()); +} + +#[test] +fn tree_mutations_copy_publish_without_replacing_and_preserve_symbolic_links() { + let root = TempDir::new("tree-mutations"); + let (session, _events) = connect(); + let request = |method: &str, params| session.request_value(method, params, STEP_TIMEOUT); + request( + methods::FS_CREATE_DIR, + json!({"path": root.join("source"), "recursive": false}), + ) + .unwrap(); + std::fs::write(root.join("source/file with spaces"), "host content").unwrap(); + std::os::unix::fs::symlink("file with spaces", root.join("source/link")).unwrap(); + let copied = request( + methods::FS_COPY, + json!({"from": root.join("source"), "to": root.join("copy"), "recursive": true}), + ) + .unwrap(); + assert_eq!(copied["skippedSpecialFiles"], 0); + assert_eq!( + std::fs::read_to_string(root.join("copy/link")).unwrap(), + "host content" + ); + assert!(std::fs::symlink_metadata(root.join("copy/link")) + .unwrap() + .is_symlink()); + std::fs::write(root.join("copy/file with spaces"), "destination edits").unwrap(); + assert_eq!( + code(request( + methods::FS_COPY, + json!({"from": root.join("source"), "to": root.join("copy"), "recursive": true}) + )), + ErrorCode::AlreadyExists + ); + assert_eq!( + std::fs::read_to_string(root.join("copy/file with spaces")).unwrap(), + "destination edits" + ); + request( + methods::FS_RENAME, + json!({"from": root.join("copy"), "to": root.join("renamed")}), + ) + .unwrap(); + request( + methods::FS_REMOVE, + json!({"path": root.join("renamed"), "recursive": true}), + ) + .unwrap(); + assert!(!std::path::Path::new(&root.join("renamed")).exists()); + assert_eq!( + std::fs::read_dir(root.path()).unwrap().count(), + 1, + "no staging directory remains" + ); + session.shutdown(STEP_TIMEOUT); +} + +#[cfg(target_os = "linux")] +#[test] +fn trash_moves_content_and_recovery_metadata_to_the_remote_account_data_directory() { + let root = TempDir::new("trash"); + let (session, _events) = support::connect_with_environment(&[("XDG_DATA_HOME", root.str())]); + std::fs::write(root.join("recover me.txt"), "keep these bytes").unwrap(); + session + .request_value( + methods::FS_TRASH, + json!({"path": root.join("recover me.txt")}), + STEP_TIMEOUT, + ) + .unwrap(); + assert!(!std::path::Path::new(&root.join("recover me.txt")).exists()); + let entries = std::fs::read_dir(root.join("Trash/files")) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!( + std::fs::read_to_string(entries[0].path()).unwrap(), + "keep these bytes" + ); + let metadata = std::fs::read_dir(root.join("Trash/info")) + .unwrap() + .next() + .unwrap() + .unwrap(); + let metadata = std::fs::read_to_string(metadata.path()).unwrap(); + assert!(metadata.contains("[Trash Info]")); + assert!(metadata.contains("recover%20me.txt")); + session.shutdown(STEP_TIMEOUT); +} + #[test] fn handshake_reports_the_host_as_capabilities() { let (session, _events) = connect(); diff --git a/rust/lithe-remote-server/tests/terminals.rs b/rust/lithe-remote-server/tests/terminals.rs index 64651d2098..9c161ae535 100644 --- a/rust/lithe-remote-server/tests/terminals.rs +++ b/rust/lithe-remote-server/tests/terminals.rs @@ -76,11 +76,15 @@ fn commands_run_on_a_controlling_terminal() { fn resizing_changes_the_size_programs_see() { let (session, _events) = connect(); let terminal = session.open_terminal(plain_shell()).unwrap(); + // Wait for shell initialization and a complete command boundary. An + // interactive shell may otherwise restore its startup size after resize. + terminal.write(b"printf 'rea%s\\n' dy\n").unwrap(); + read_until(&terminal, "ready"); terminal.write(b"stty size\n").unwrap(); - read_until(&terminal, "40 100"); + read_until(&terminal, "40 100\r\n$ "); terminal.resize(132, 50).unwrap(); terminal.write(b"stty size\n").unwrap(); - read_until(&terminal, "50 132"); + read_until(&terminal, "50 132\r\n$ "); terminal.close().unwrap(); read_until_exit(&terminal); drop(terminal); diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 5f0747da99..e5a1f28fc8 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -399,6 +399,11 @@ async function main() { // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. // Includes remote-server-artifacts: packaging rebuilds them from this worktree's sources. // Includes remote-connection-state: SSH settings, keychain secrets and placeholder roots are user-owned. + // Includes remote-workspace-session-state: host Git scratch and connection + // discovery/index snapshots, copy staging, account Trash and watch subscriptions + // have execution/user ownership, never a build stamp or copy stage. + // Build/provider state, approved plugin-run snapshots, upload staging and + // native marketplace extraction also stay on that rejection route. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging // and active/previous approvals are runtime-owned. Reject before resolving worktrees // or calling validatorArguments; a content digest is neither a build stamp nor consent. diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 0bdabe9e56..d694636d87 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,29 @@ function reuse(extraArguments = []) { } try { + await test("remote host resources and session snapshots cannot cross worktrees", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + for (const id of ["remote-server-artifacts", "remote-connection-state", "remote-workspace-session-state"]) { + const resource = registry.excludedResources.find(item => item.id === id); + assert.equal(resource.reusable, false); + assert.ok(!listed.stdout.includes(id)); + const refused = reuse(["--resource", id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), new RegExp(`${id} is isolated.*cannot be reused`)); + } + const state = registry.excludedResources.find(item => item.id === "remote-connection-state"); + assert.ok(state.locations.some(location => location.includes("session-and-binary-hash"))); + const snapshots = registry.excludedResources.find(item => item.id === "remote-workspace-session-state"); + assert.ok(snapshots.locations.some(location => location.includes("lithe-git-"))); + assert.ok(snapshots.locations.some(location => location.includes(""))); + assert.ok(snapshots.locations.some(location => location.includes(".lithe-copy-"))); + assert.ok(snapshots.locations.some(location => location.includes("account-or-volume-trash"))); + for (const component of [".lithe-upload-", "plugin-runs", "state/plugins", "lithe-plugin-download-"]) { + assert.ok(snapshots.locations.some(location => location.includes(component)), component); + } + }); await test("shared translations are regenerated locally rather than reused", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "shared-i18n"); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 89ea9c4c36..a423ed47f0 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -377,10 +377,31 @@ "/lithe.remote.ssh-connections.v1", "//remote.ssh..secret", "/Lithe/RemoteWorkspaces///", - "/~/.lithe-server/bin/-" + "/~/.lithe-server/bin/-", + "/~/.lithe-server/run/.{sock,lock}", + "/~/.lithe-server/log/.log" ], "identity": "User-owned SSH connection settings, remembered secrets, empty placeholder roots of opened remote workspaces, and servers installed on remote hosts; no build identity.", - "reason": "Connections and secrets belong to the user's profile and the keychain. Placeholder roots are empty directories that key per-project state for remote folders and hold no files. Remote installs live on the hosts and are verified by SHA-256 before reuse. None has a copy stage between worktrees, and none is written into the app bundle or installation directory." + "reason": "Connections and secrets belong to the user's profile and the keychain. Placeholder roots are empty directories that key per-project state for remote folders and hold no files. Remote installs live on the hosts and are verified by SHA-256 before reuse. Daemon socket, lock and log identity includes the session key and complete executable SHA-256, so rebuilding an unchanged version cannot reconnect to older code. None has a copy stage between worktrees, and none is written into the app bundle or installation directory." + }, + { + "id": "remote-workspace-session-state", + "reusable": false, + "locations": [ + "//{lithe-git-snapshot,lithe-line-history}-", + "//lithe-commit-", + "//.lithe-copy--", + "//{files,info}", + "//.lithe-upload--", + "/~/.lithe-server/plugin-runs//package", + "/~/.lithe-server/state/plugins//{build-systems,vcs-providers,language-servers}//workspaces/", + "/lithe-plugin-download-", + "//remote-watch-subscriptions", + "//remote-runtime-and-shell-snapshots", + "//remote-git-executable-and-search-index" + ], + "identity": "Mutable per-operation Git/build scratch, private plugin snapshots, connection-owned upload staging, provider workspace state and per-connection runtime, shell, executable and search snapshots. They have no reusable version/platform/architecture/toolchain build identity.", + "reason": "The existing workspace engines own these records while generic host filesystem/process/PTY adapters perform I/O. Git/build scratch lives on its host and is never mirrored into the local placeholder. Upload staging expires or is removed on abort/disconnect; completed plugin runs are removed after process destruction, but crash/disconnect recovery of published runs remains TODO. Provider state persists on the host. Native marketplace extraction is deleted after transfer or failure. Package trust/hash/architecture checks authorize one execution and do not establish reusable build identity. Host discovery is memory-only and invalidated on explicit refresh or connection replacement. All reuse stages reject these entries before validation or copying. Runtime writes do not reach installed bundles, code signatures or Sparkle delta baselines." }, { "id": "shared-i18n", diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/shared/contracts/remote-workspace-protocol-v1.md index 898dfd2b53..1c24f71380 100644 --- a/shared/contracts/remote-workspace-protocol-v1.md +++ b/shared/contracts/remote-workspace-protocol-v1.md @@ -51,11 +51,21 @@ uses this stream. - Field names are camelCase. Byte payloads (`data`) are standard Base64. - Paths are absolute paths on the remote host. Relative paths fail with `INVALID_PATH`. -- Requests that change a running resource in order — `process.writeStdin`, - `process.closeStdin`, `process.kill`, `terminal.write`, `terminal.resize`, - `terminal.close`, `tunnel.write`, `tunnel.close`, `watch.stop`, - `walk.cancel` — are applied in the order sent. Other requests may complete - in any order. +- Queued input and resize requests — `process.writeStdin`, `process.closeStdin`, + `terminal.write`, `terminal.resize`, `tunnel.write`, `tunnel.close`, + `walk.cancel` — are applied in the order sent. Potentially blocking creation + and teardown run on workers; other requests may complete in any order. +- A `process.kill`, `terminal.close`, or `watch.stop` received while the same + resource is starting records teardown before worker scheduling. The creating + worker applies it after publication, even if the close worker is rejected as + `BUSY`, its caller times out, or the connection drops. A close waiter has a + 60-second deadline; expiry does not abandon the recorded teardown. Malformed + controls never cancel creation. Forced kills cannot be downgraded by a later + non-forced kill. Unrelated resources remain independent and no registry lock + covers spawning, filesystem I/O, or process-tree termination. +- A nonpersistent disconnect and every session shutdown also reclaim late + publications. Persistent disconnects alone retain resources for reconnection; + explicit shutdown or idle expiry must not leave a late-created resource alive. ## Resources and events @@ -83,13 +93,18 @@ subscribe before creating the resource. | `fs.readDir` | `path` | `entries` sorted by name byte order, `skippedNonUtf8` | | `fs.readFile` | `path`, `offset`, `length` (≤ 8 MiB) | `data`, `eof`, `metadata` | | `fs.writeFile` | `path`, `data` (≤ 8 MiB), `expected?`, `overwrite`, `createParents` | metadata after the write | -| `fs.createDir` | `path`, `recursive` | `null` | +| `fs.createDir` | `path`, `recursive`, `mode?` (at most `0o7777`) | `null` | | `fs.remove` | `path`, `recursive` | `null`; links are removed, never followed | | `fs.rename` | `from`, `to`, `overwrite` | `null` | | `fs.copy` | `from`, `to`, `recursive`, `overwrite` | `skippedSpecialFiles`; links are copied as links | +| `fs.trash` | `path` | `null`; moves the entry to the host account's recoverable system trash | | `fs.realPath` | `path` | `path` with links resolved | | `fs.setPermissions` | `path`, `mode` (≤ `0o7777`) | `null` | | `fs.volumeInfo` | `path` | `caseSensitive` (`null` when unknown) | +| `fs.uploadStart` | `path`, `size`, `sha256`, `mode` | `uploadId` | +| `fs.uploadChunk` | `uploadId`, `offset`, `data` | next `offset` | +| `fs.uploadCommit` | `uploadId` | verified `size`, `sha256` | +| `fs.uploadAbort` | `uploadId` | `null` | | `walk.start` | `walkId`, `root`, `excludeNames`, `maxEntries?` | `null`; entries follow as events | | `walk.cancel` | `walkId` | `null` | | `watch.start` | `watchId`, `root`, `excludeNames` | `watchedDirectories` (per-directory backends) | @@ -114,6 +129,15 @@ directly, through a symbolic link in either direction or onto a hard link — and to copy a directory into itself after resolving links. Sockets, FIFOs and devices are never copied: at the top level the request fails with `UNSUPPORTED`, inside a tree they are skipped and counted. +With `overwrite: false`, a private sibling staging directory holds the copy +until an atomic no-replace rename publishes it. A concurrent destination +creation fails with `ALREADY_EXISTS` and leaves that destination unchanged. + +`fs.trash` uses the host's native trash implementation (Freedesktop Trash on +Linux and the system Trash on macOS). Recovery content and metadata belong to +the remote account, never to the client placeholder or installation. A host +without usable trash storage returns an error and retains the original entry; +it does not fall back to permanent deletion. `fs.writeFile` replaces the file atomically through a temporary sibling and writes through symbolic links. With `expected` (`size`, `modifiedMs`) it fails @@ -123,6 +147,19 @@ only replaced when `overwrite` is set. A write with neither `expected` nor the other fails with `ALREADY_EXISTS`. When a file is replaced, the temporary file never has broader permissions than the original. +Hosts advertising `features: ["upload", ...]` accept staged uploads without +increasing the frame limit. Upload IDs are server-assigned and owned by the +connection that created them, including in a persistent session. At most four +uploads are staged per server; each file is at most 2 GiB. Chunks contain +1–256 KiB of decoded bytes and must name the exact acknowledged offset. +`sha256` is a 64-character hexadecimal digest; `mode` permits only `0o000`–`0o777`. +Staging uses a private `0600` sibling file. Commit verifies size and digest, +syncs the file and atomically publishes without replacing an existing target. +Failed commits, aborts, disconnects, session shutdown and an absolute five-minute +expiry remove staging files. Clients serialize chunks and commit only after +the last acknowledgement. Interrupted clients may not know whether a completed +commit published; owners must remove their allocated run directory separately. + `process.exit` and `terminal.exit` report the leader. The server keeps owning its session until no member is left, so members that outlive the leader can still be killed and are cleaned up when the session ends; `server.state` diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f56ffb8fdc..4e799ca5c9 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3528,6 +3528,13 @@ the remote server running. Requests over it take `sessionId`: | `remote.readDir` | `{ sessionId, path }` | the remote protocol's `fs.readDir` result | | `remote.readFile` | `{ sessionId, path }` | `{ data (base64), size, modifiedMs }`; files over 16 MiB are `not_supported` | | `remote.writeFile` | `{ sessionId, path, data, expected? }` | `{ status: "saved", size, modifiedMs }` or `{ status: "conflict" }` | +| `remote.hostRequest` | `{ sessionId, method, params }` | the finite host method's result, including JSON `null` | +| `remote.hostInfo` | `{ sessionId }` | the original connection's handshake `host`, including OS, architecture, home and features | +| `remote.uploadFile` | `{ sessionId, sourcePath, path, sha256, mode }` | verified `{ size, sha256 }` | +| `remote.runWatch` | `{ sessionId, params: { root, excludeNames? } }` | waits until root removal or cancellation; relays `watch.started`, `watch.changed`, `watch.error` | +| `remote.runProcess` | `{ sessionId, params: { argv, cwd?, environment?, env?, pipeStdin? } }` | leader exit; relays `process.started`, `process.output`, `process.exit` | +| `remote.runTerminal` | `{ sessionId, params: { argv?, cwd?, env?, columns, rows } }` | leader exit; relays `terminal.started`, `terminal.output`, `terminal.exit` | +| `remote.captureProcess` | `{ sessionId, params, timeoutMilliseconds, maxOutputBytes }` | base64 `stdout`, `stderr`, nullable `exitCode` and `signal` | `remote.writeFile` replaces the file atomically on the host. With `expected` (`{ size, modifiedMs }` from the read) a changed file is a @@ -3536,6 +3543,37 @@ the remote server running. Requests over it take `sessionId`: fails with `connection_closed` and is forgotten, so the host reconnects; an unknown or closed `sessionId` is also `connection_closed`. +`remote.hostRequest` accepts finite filesystem, environment and port queries, +plus controls of existing processes and terminals. Creating a watch, process, +terminal, walk or tunnel through that command is `invalid_request`; creation +requires an owner that subscribes before sending the start request. Streamed +Core events use `{ type: "remote", event, params }`. Resource IDs are allocated +by Core and replace any caller-supplied ID. Startup output is bounded to 8 MiB. +Cancellation, invalid event data and disconnect release the subscription and +stop the host resource with a two-second cleanup deadline. +That deadline bounds the client's wait, not ownership: when creation has not +published yet, the host records teardown against the pending resource ID and +the creating worker cleans it after publication. A rejected or timed-out close +worker cannot turn an already accepted cancellation into an orphaned resource. + +Watch startup emits `watch.started` before buffered changes. The caller rescans +on startup, `overflow`, or watch error; `ROOT_REMOVED` ends the call. Watch paths +remain absolute host paths until the native adapter maps the owning workspace +back to placeholder URLs. A limit error preserves the partially working watch +and requests a rescan. Process capture requires a 1–30,000 ms deadline and +1–8 MiB output budget, rejects open stdin, and owns cleanup on overflow/timeout. + +`remote.uploadFile` reads a native absolute regular file and streams it to the +specified host using the optional `upload` feature. It verifies source size, +chunk acknowledgements and the commit receipt; failure attempts an uncancelled +abort with a two-second deadline. Each host call has a 30-second deadline, while +the host imposes the five-minute staging lifetime. This command does not approve +plugin code: the platform package adapter validates trust, version, manifest and +target architecture before invoking it. Target publication never overwrites. +Successful upload ownership transfers to the caller, which removes its run +snapshot after the process has ended. The native source and installation remain +read-only. See the staged upload limits in `remote-workspace-protocol-v1.md`. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index fbe1ace5b0..1137637882 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9369,7 +9369,25 @@ "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", + "native.remoteTerminalAsyncStart": "The remote terminal must be started asynchronously.", + "native.remoteTerminalInvalidDirectory": "The terminal directory is outside the remote workspace.", + "native.remoteTerminalClosedBeforeStart": "The remote terminal closed before it started.", + "native.remoteTerminalInputBusy": "The remote terminal is busy. Wait before sending more input.", + "native.remoteGitMixedHosts": "A Git operation cannot span different workspace hosts.", + "native.remoteGitMixedLocal": "A Git operation cannot span local and remote workspaces.", + "native.runtimeDiscoveryFailed": "Runtime discovery failed", + "native.remoteRuntimeDiscoveryFailed": "Could not discover runtimes on the remote host. Check the connection and refresh.", + "native.runtimeDiscoveryRecovery": "Check the workspace connection and refresh the available runtimes.", + "native.remoteRuntimeToolMissing": "The required tool is not available on the remote host.", + "native.remoteRuntimeToolRecovery": "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes.", + "native.remoteShellDiscoveryFailed": "Could not discover shells on the remote host. Check the connection and try again.", + "remote.copyIncomplete": "Some special files could not be copied", + "remote.pendingDocumentsLimit": "Remote pending documents exceeded the startup limit. Wait for the language server to start.", + "remote.pluginUnapproved": "The remote plugin package is not part of the approved installation.", + "remote.pluginHostUnsupported": "This host cannot receive a compatible plugin package. Update the remote server.", + "remote.pluginApprovalRequired": "The remote plugin package changes approved commands or permissions. Install and approve that version first.", + "remote.pluginPlatformUnavailable": "Plugin {plugin} has no approved package for {platform}. Install a compatible plugin version." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 9ecb3d1af3..c76198400a 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9369,7 +9369,25 @@ "native.openLocalFolder": "本地文件夹", "native.size": "大小", "native.thisFolderCannotBeRead": "无法读取此文件夹。", - "native.thisFolderIsEmpty": "此文件夹为空" + "native.thisFolderIsEmpty": "此文件夹为空", + "native.remoteTerminalAsyncStart": "远程终端需要异步启动。", + "native.remoteTerminalInvalidDirectory": "终端目录不在远程工作区内。", + "native.remoteTerminalClosedBeforeStart": "远程终端在启动完成前已关闭。", + "native.remoteTerminalInputBusy": "远程终端正忙,请稍后再发送输入。", + "native.remoteGitMixedHosts": "一次 Git 操作不能跨越不同的工作区主机。", + "native.remoteGitMixedLocal": "一次 Git 操作不能同时跨越本地和远程工作区。", + "native.runtimeDiscoveryFailed": "运行环境探测失败", + "native.remoteRuntimeDiscoveryFailed": "无法探测远程主机的运行环境,请检查连接后刷新。", + "native.runtimeDiscoveryRecovery": "请检查工作区连接并刷新可用运行环境。", + "native.remoteRuntimeToolMissing": "远程主机上没有可用的所需工具。", + "native.remoteRuntimeToolRecovery": "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。", + "native.remoteShellDiscoveryFailed": "无法探测远程主机上的 Shell。请检查连接后重试。", + "remote.copyIncomplete": "部分特殊文件未能复制", + "remote.pendingDocumentsLimit": "远程语言服务启动前的待同步文档已达到上限,请等待启动完成。", + "remote.pluginUnapproved": "远程插件包不属于已批准的安装。", + "remote.pluginHostUnsupported": "此主机无法接收兼容的插件包,请更新远程服务。", + "remote.pluginApprovalRequired": "远程插件包更改了已批准的命令或权限,请先安装并批准该版本。", + "remote.pluginPlatformUnavailable": "插件 {plugin} 没有适用于 {platform} 的已批准包,请安装兼容版本。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 641429d338..5c734d17e1 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -89155,6 +89155,211 @@ "parameters": { "arg1": "string" } + }, + { + "id": "native.remoteTerminalAsyncStart", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote terminal must be started asynchronously.", + "arguments": [] + } + }, + { + "id": "native.remoteTerminalInvalidDirectory", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The terminal directory is outside the remote workspace.", + "arguments": [] + } + }, + { + "id": "native.remoteTerminalClosedBeforeStart", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote terminal closed before it started.", + "arguments": [] + } + }, + { + "id": "native.remoteTerminalInputBusy", + "description": "Remote terminal failure", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote terminal is busy. Wait before sending more input.", + "arguments": [] + } + }, + { + "id": "native.remoteGitMixedHosts", + "description": "Remote workspace host isolation", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Git operation cannot span different workspace hosts.", + "arguments": [] + } + }, + { + "id": "native.remoteGitMixedLocal", + "description": "Remote workspace host isolation", + "platforms": [ + "macos" + ], + "macos": { + "key": "A Git operation cannot span local and remote workspaces.", + "arguments": [] + } + }, + { + "id": "native.runtimeDiscoveryFailed", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime discovery failed", + "arguments": [] + } + }, + { + "id": "native.remoteRuntimeDiscoveryFailed", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not discover runtimes on the remote host. Check the connection and refresh.", + "arguments": [] + } + }, + { + "id": "native.runtimeDiscoveryRecovery", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Check the workspace connection and refresh the available runtimes.", + "arguments": [] + } + }, + { + "id": "native.remoteRuntimeToolMissing", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "The required tool is not available on the remote host.", + "arguments": [] + } + }, + { + "id": "native.remoteRuntimeToolRecovery", + "description": "Workspace host runtime discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes.", + "arguments": [] + } + }, + { + "id": "native.remoteShellDiscoveryFailed", + "description": "Remote terminal shell discovery failure and recovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not discover shells on the remote host. Check the connection and try again.", + "arguments": [] + } + }, + { + "id": "remote.copyIncomplete", + "description": "A remote directory copy skipped sockets, devices or named pipes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some special files could not be copied", + "arguments": [] + } + }, + { + "id": "remote.pendingDocumentsLimit", + "description": "Bounded document snapshots while a remote backend is starting.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Remote pending documents exceeded the startup limit. Wait for the language server to start.", + "arguments": [] + } + }, + { + "id": "remote.pluginUnapproved", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote plugin package is not part of the approved installation.", + "arguments": [] + } + }, + { + "id": "remote.pluginHostUnsupported", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This host cannot receive a compatible plugin package. Update the remote server.", + "arguments": [] + } + }, + { + "id": "remote.pluginApprovalRequired", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The remote plugin package changes approved commands or permissions. Install and approve that version first.", + "arguments": [] + } + }, + { + "id": "remote.pluginPlatformUnavailable", + "description": "Remote plugin deployment validation failure.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Plugin %@ has no approved package for %@. Install a compatible plugin version.", + "arguments": [ + "plugin", + "platform" + ] + }, + "parameters": { + "plugin": "string", + "platform": "string" + } } ] } diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index 64d144cec4..3d09149d0f 100644 --- a/shared/platform-feature-matrix/features/remote-development-workspace.json +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -9,6 +9,12 @@ "macos/Sources/Lithe/Application/Features/RemoteConnectionsFeatureModel.swift", "macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift", "macos/Tests/LitheTests/RemoteConnectionsTests.swift", + "macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift", + "macos/Tests/LitheTests/RemoteTerminalTransportTests.swift", + "macos/Tests/LitheTests/RemoteShellDiscoveryTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteTerminalTransport.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift", + "rust/lithe-core/src/remote/sessions_integration_tests.rs", "rust/lithe-core/src/remote", "rust/lithe-remote-client/src/launch.rs", "rust/lithe-remote-protocol", @@ -19,7 +25,16 @@ ".agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md", ".agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md", "macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift", - "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift" + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift", + "macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift", + "macos/Tests/LitheTests/RemoteBuildExecutionTests.swift", + "macos/Tests/LitheTests/RemotePluginDeploymentTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift", + "macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift", + "rust/lithe-remote-server/src/connection/tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -32,5 +47,5 @@ "verificationStatus": "not-applicable" }, "owner": "Workspace", - "verification": "欢迎页点“远程开发”:新建一条 Linux SSH 连接(分别试 SSH Agent、私钥和密码,密码主机再试一次 2FA),测试连接后核对主机信息卡片,手动查询地区;点“打开”后文件树列出远端目录,打开、编辑并保存一个文件,在远端用其他方式修改同一文件后再次保存应提示冲突。远程 Mac、容器、Git、搜索、终端、构建和文件监听尚未接入远程工作区。Rust 后端由 scripts/verify-remote-workspace.sh 验证。 远程开发窗口与 Open 面板的尺寸随父窗口缩小(远程开发最小 640×420),在 800×650 窗口中确认主机卡片的地址行分两行显示、位置信息换行完整、架构标签与底部按钮不被裁掉,连续编辑字段尺寸不再变化。" + "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、完整插件快照的断线/崩溃回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。" } diff --git a/shared/platform-feature-matrix/features/remote-workspace.json b/shared/platform-feature-matrix/features/remote-workspace.json index 4160257691..a06143ffb8 100644 --- a/shared/platform-feature-matrix/features/remote-workspace.json +++ b/shared/platform-feature-matrix/features/remote-workspace.json @@ -5,10 +5,21 @@ "capability": "远程连接与远程路径工作区", "macos": { "evidence": [ - "macos/Sources/Lithe/Services" + "macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift", + "macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift", + "macos/Tests/LitheTests/RemoteConnectionsTests.swift", + "macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift", + "macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift", + "macos/Tests/LitheTests/RemoteBuildExecutionTests.swift", + "macos/Tests/LitheTests/RemotePluginDeploymentTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteBuildCommandRunner.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", + "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift", + "macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift", + "rust/lithe-remote-server/src/connection/tests.rs" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "partial", + "verificationStatus": "pending" }, "windows": { "evidence": [ @@ -19,5 +30,5 @@ "verificationStatus": "pending" }, "owner": "Remote", - "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 需要补充产品入口或明确不支持。" + "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 从欢迎页远程开发入口连接 Linux SSH 主机,验证文件树、编辑保存冲突、断线通知与重连。macOS 路由与断线 fixture 已有自动测试,真实 SSH 流程仍待验收;完整远程开发进度见 remote-development-workspace。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 构建与插件接入进度同步至 remote-development-workspace;本行保持 partial/pending,不能以本机 fixture 替代真实 SSH 验收。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。" } From 7203ddd288a13b8b086b3dad192bbb21d8e96420 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 27 Oct 2026 22:18:21 +0800 Subject: [PATCH 3/5] fix(remote): own plugin snapshots and isolate upload I/O Add session-owned run directory allocation and cleanup with retryable errors, original-session macOS deployment cleanup, and per-upload I/O isolation. Cover lifecycle and upload ordering regressions, update contracts/resource boundaries and capability evidence, and record optional transfer/environment candidates without expanding Agent or Java scope. --- .../2026-10-24-remote-workspace-routing.md | 6 +- ...10-24-remote-development-workspace-host.md | 91 +++++- docs/ci-builds.md | 6 +- .../MacRemotePluginPackageDeployer.swift | 38 +-- .../RemotePluginDeploymentTests.swift | 55 +++- rust/lithe-core/src/remote/workspace_host.rs | 4 + rust/lithe-remote-protocol/src/methods.rs | 12 + rust/lithe-remote-server/src/connection.rs | 26 ++ .../src/connection/tests.rs | 45 +++ rust/lithe-remote-server/src/lib.rs | 1 + rust/lithe-remote-server/src/platform/mod.rs | 1 + rust/lithe-remote-server/src/resources.rs | 25 +- rust/lithe-remote-server/src/run_directory.rs | 188 ++++++++++++ rust/lithe-remote-server/src/session.rs | 6 + rust/lithe-remote-server/src/upload.rs | 268 ++++++++++++------ rust/lithe-remote-server/src/upload/tests.rs | 103 +++++++ .../tests/run_directories.rs | 246 ++++++++++++++++ scripts/reuse-worktree-resources.mjs | 2 + scripts/test-reuse-worktree-resources.mjs | 2 + scripts/worktree-resources.json | 2 +- .../contracts/remote-workspace-protocol-v1.md | 30 ++ shared/contracts/rust-core-api.md | 12 + .../remote-development-workspace.json | 7 +- .../features/remote-workspace.json | 7 +- 24 files changed, 1040 insertions(+), 143 deletions(-) create mode 100644 rust/lithe-remote-server/src/run_directory.rs create mode 100644 rust/lithe-remote-server/src/upload/tests.rs create mode 100644 rust/lithe-remote-server/tests/run_directories.rs diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md index ac4563deac..7e4ff564a3 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -25,7 +25,9 @@ - **终端确认与清理**:交互终端默认使用远端账户的 Shell,显式菜单选择保留独立 argv;调试进程按工作区身份选择主机,即使调试器给出的工作目录已经是远端绝对路径。启动在收到远端确认后才发布就绪与 PID,等待中的启动也算模块活动资源。重启先等待上次传输清理,输入按顺序发送,待发送尺寸只保留最新值;写入与缩放复用 Core 的操作取消,退出释放远程渲染标记,不把远端 PID 交给本机进程监控。 - **运行时与 Shell 发现**:网络调用在平台工作线程运行,界面读取缓存;发现有数量、数据和时间限制,取消或切换工作区后丢弃旧结果,失败不发布部分结果或本机回退。Shell 从远端登录环境、`/etc/shells` 和文件权限发现,旧服务缺目标权限时再查询链接真实路径;本机终端设置里的可执行文件路径不作为远程默认值。 - **资源生命周期**:Git 临时索引和源文件快照在远端临时目录或 Git common directory,由单次操作清理;探测、可执行文件与搜索缓存保存在客户端内存并按连接隔离。服务的 socket、锁和日志身份同时包含会话键与二进制 SHA-256,版本号相同但内容变化时不复用旧常驻进程。以上资源在 `scripts/worktree-resources.json` 中明确排除跨工作树复制,运行时不写安装资源,不改变签名和 Sparkle delta 基线。 -- **构建与插件运行**:构建扫描、工具探测、构建进程和报告读取使用原工作区的主机身份,stdout/stderr 分别增量解码。插件只部署经过原安装来源复核的包,异平台变体必须同版本、同声明且不增加批准程序;复用市场校验和下载器,不向小型服务引入包管理或语言引擎。目标文件通过带 SHA-256 的分块上传原子发布,普通文件只读、可执行文件只读可执行,可写状态位于远端用户目录。快照清理捕获原连接,在 Core 销毁进程后执行;完整快照的断线与崩溃回收仍待补齐,不能把上传暂存清理当成完整快照清理。 +- **构建与插件运行**:构建扫描、工具探测、构建进程和报告读取使用原工作区的主机身份,stdout/stderr 分别增量解码。插件只部署经过原安装来源复核的包,异平台变体必须同版本、同声明且不增加批准程序;复用市场校验和下载器,不向小型服务引入包管理或语言引擎。目标文件通过带 SHA-256 的分块上传原子发布,普通文件只读、可执行文件只读可执行,可写状态位于远端用户目录。快照清理捕获原连接,在 Core 销毁进程后执行。 +- **完整运行目录由远端会话保管**:客户端先生成运行目录身份,再请求 `fs.createRunDirectory`,即使分配回复丢失也能用原身份释放。服务端只在远端用户的私有 `~/.lithe-server/plugin-runs/` 下新建目录,不接管已经存在的路径;释放仅按当前会话持有的身份查找,不接受任意删除路径,重复释放无害;删除失败保留登记供重试,即使释放正在等待创建者清理,也会收到真实错误而不是成功。已有父目录必须为当前用户所有、非符号链接且不向其他用户开放,不通过改权限接管共享目录。分配和释放沿用读取线程预登记与创建者清理规则。非驻留断线、会话关闭和空闲到期在结束进程、排空工作线程后回收完整目录,客户端崩溃不再依赖客户端补发删除。常驻会话仅断线时保留资源,供重连继续使用。远端服务被强制杀死后的回收尚未实现:尤其 macOS 可能留有进程,不能只用目录年龄或服务锁消失就删除运行输入。旧版客户端创建的未登记快照也不自动扫描删除。 +- **上传不互相拖住**:上传表只登记和移除身份,不把文件打开、写入、同步、发布或删除放在全局锁里。每个文件独立同步,同一上传的并发操作返回 `BUSY`;断线、取消和到期先标记取消,由仍在执行的工作线程保管并最终删除暂存文件。一个慢文件不能阻止另一个文件完成,释放动作也不能等慢文件的锁。 - **stdio 与启动**:语言、构建和 VCS 提供者复用 Core 的协议引擎,远程通道保留有界输入输出与原连接。异步部署、工具探测和启动在工作线程进行,启动期间保留有界文档快照与文件变更,停止后到达的会话先销毁再删除包。仅映射协议定义的 URI 和 VCS 路径字段,参数、文本与插件自定义值保持原样。依赖本机原生集成的 Java 仍明确拒绝,不能伪装成通用 stdio 已支持。 - **启动中取消不能依赖空资源表**:服务端的连接读取线程先登记进程、终端和监听的启动身份,再交给工作线程。后续关闭先记录在这个身份上,由创建者在发布资源后执行清理;即使关闭请求因工作线程已满返回 `BUSY`、客户端停止等待或断线,清理责任也不消失。关闭等待最多 60 秒,但到期不会撤销已经记录的清理;不同资源仍可并行,注册表锁不覆盖磁盘、启动或结束进程树。无效参数不能触发取消,后到的普通结束请求不能降低已请求的强制结束。这样修复的是所有通用后端的生命周期,不需要引入 Java/JDT 等语言专属语义。 - **断线与会话结束不同**:常驻会话仅断线时保留终端和进程供重连;非驻留连接断开、显式关闭会话或空闲到期时,迟到发布的资源必须由创建者清理。结束标记在清理现有资源表之前设置,否则慢启动可能在清理后才出现,尤其是常驻会话不能再把它当成“等待重连”的合法资源。 @@ -61,7 +63,7 @@ - 新增适配覆盖:远端 PTY、按主机探测环境、Git 文件与进程 I/O、搜索索引与 Shift+Shift 的文件及内容查询。真实服务 fixture 已覆盖状态、暂存、分页历史、设置来源、worktree 创建和管理、克隆、保留无关暂存的选择文件提交,以及未保存缓冲区的行历史;不能据此把完整真实 SSH 产品流程标为已验收。 - 文件树新建、复制、重命名、移到主机废纸篓和普通删除已接入;外部修改监听覆盖文件树与文档,真实 SSH 的原生 watcher 仍需产品验收。 - 构建 I/O、工具与状态目录适配,以及通用插件部署和 stdio 提供者接入已有实现与 fixture;真实 SSH 的完整构建、插件启动与取消链路仍待验收。 -- 仍待完成:远程 Java 原生集成、SVN 的兼容目标包与完整 VCS 流程、Agent 执行位置选择、完整快照断线/崩溃回收,以及 stdio 断线、顺序、背压与取消的真实主机覆盖。当前分支按 Draft PR 分阶段评审,不关闭 #1507。 +- 仍待完成:远程 Java 原生集成、SVN 的兼容目标包与完整 VCS 流程、Agent 执行位置选择、远端服务强制终止后的快照回收,以及 stdio 断线、顺序、背压与取消的真实主机覆盖。Java 和 Agent 不在本轮通用能力续作范围;不能把桌面客户端崩溃后的会话到期清理当成远端服务崩溃回收。不关闭 #1507。 - 连接在工作区关闭后仍保留,直到 App 退出。 - 代价:主机调用增加往返时间,远端无有效工具或连接中断时必须明确失败;缓存与长期资源都需要保留原连接身份,不能复用本机路径命中的结果。 - 当前限制:Git 原子写入仍保留 8 MiB 上限;新分块上传仅用于已批准插件文件,不代表 Git 大文件临时内容已经接入。 diff --git a/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md b/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md index 33fa3f4e73..1c5cce1a22 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md +++ b/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md @@ -6,7 +6,7 @@ Lithe 要支持“完整的远程开发”:项目放在 SSH 主机、Docker 容器(包括 Dev Container)或远程 Mac 上,本地只负责界面,文件读写、终端、构建、运行、调试、LSP、Git 和 Agent 都在远端执行。做法是把远端能力全部放进一个独立的远端服务 `lithe-remote-server`。SSH、`docker exec` 这些连接方式只叫传输链(transport chain,从本机到工作区所在位置的一串跳转),它们只负责“把服务启动起来、接通标准输入输出”,不实现任何功能。Linux 和 macOS 远端的差异放在服务内部的平台适配器里,并且以能力描述(capabilities,服务在握手时报告的主机事实)的形式告诉客户端。客户端只看能力,不写“如果远端是 Mac 就怎样”的分支。 -这不是插件能完成的事情,它是宿主的核心能力。插件以后只需要声明“我的后端跑在本地界面侧,还是跑在远端工作区侧”。目前已经落地的是 Rust 后端:协议、远端服务(文件、目录遍历、文件监听、进程、终端、登录环境、隧道和端口)、断线后不中断的常驻会话、传输链、远端安装和客户端 API。产品界面暂不接入,Windows 暂不考虑。 +这不是插件能完成的事情,它是宿主的核心能力。插件以后只需要声明“我的后端跑在本地界面侧,还是跑在远端工作区侧”。本文保留最初的分层与阶段提案,不作为当前完成度清单;已经接入的产品路径以[远程工作区按根目录路由](../../implemented/architecture/2026-10-24-remote-workspace-routing.md)和平台功能矩阵为准。2026-10-27 补充的“待评估能力候选”只记录可选方向,未批准、未排期,不要求全部纳入 #1515;Agent 执行位置与 Java/JDT 专属能力仍留到后续独立讨论,开头的完整远程愿景不代表已经选定 Agent 必须远端运行。 ## 问题 @@ -179,7 +179,87 @@ inotify 的坑:目录是逐个注册的,新建目录要在收到创建事件 3. **M2**:macOS 应用中的工作区后端接口,把现有的直接调用迁到本地实现,再接入“打开远程文件夹”。这一步的接口设计另写 Note。 4. **M3**:Git(凭据请求回传到本地,复用现有的 askpass 入口)、运行和构建、端口自动转发(HTML 预览访问转发后的地址)。 5. **M4**:插件声明运行位置和支持的平台,插件包在本地验签后上传,远端再校验哈希;LSP 和调试适配器在远端运行。 -6. **M5**:Dev Container。按 `devcontainer.json` 规范构建和启动容器时复用官方的 `@devcontainers/cli`,Lithe 只负责用容器跳转连进去。精简镜像里没有 `sh` 时,改用 `docker cp` 上传。Agent 在远端运行。 +6. **M5**:Dev Container。按 `devcontainer.json` 规范构建和启动容器时复用官方的 `@devcontainers/cli`,Lithe 只负责用容器跳转连进去。精简镜像里没有 `sh` 时,评估 `docker cp` 上传。Agent 执行位置另行讨论,不预设随 M5 一起实现。 + +### 待评估能力候选(2026-10-27,未决定、未排期) + +这一节是可以反复取舍的候选计划,不是下一轮自动执行清单。每一项都处于“待用户决定”,可以单独采纳、推迟或放弃;编号只用于后续讨论,不表示优先级、完成状态或工期承诺。文件上传下载、分片、超大文件并发传输、终端环境重载和选择式传递由用户提出;其余为补充候选。 + +#### 已有基础不等于完整功能 + +当前分块上传主要服务已批准的插件包:每文件最多 2 GiB、每会话服务实例最多四个暂存上传、256 KiB 分块、同一文件严格按 offset 顺序确认,发布前校验 SHA-256,禁止覆盖目标。文件之间的锁隔离不等于已经实现传输任务调度或单文件分片并发。 + +已有按范围分段读取不等于面向用户的流式下载器;某些调用方仍会把整份文件收进内存。Core 的普通文件装载与原子写入预算继续独立生效,不能为“支持超大文件”一并放大编辑器、Git、JSON 消息或内存上限。登录环境已有会话缓存与显式刷新请求,也不等于能刷新已经运行的 shell、LSP 或构建进程。 + +#### 文件上传下载与批量操作 + +| 编号 | 候选能力 | 做之前要决定的边界 | +| --- | --- | --- | +| T01 | 从文件树或系统文件选择器上传、下载任意文件,支持二进制和空文件 | 明确本地/远端两端、目标目录、覆盖确认与只读权限;不借插件上传权限自动授权普通用户文件覆盖 | +| T02 | 文件夹、多选和拖放批量传输 | 符号链接、权限、时间戳、隐藏文件、排除规则、大小写与 Unicode 文件名冲突、特殊文件如何处理;先预览,不跟随链接越界 | +| T03 | 传输任务列表 | 逐文件与整批进度、速度、剩余时间估计、取消、失败原因和只重试失败项;未知总量时不伪造百分比,断线和切换项目后不换目标主机 | +| T04 | 暂停、继续与应用重启后恢复 | 暂停保留多久、空间由谁承担、用户如何清除;任务记录不得携带凭据,恢复前重新验证主机身份与源文件身份 | + +#### 超大文件、分片与并发模式 + +这里的“分片”指把一个文件分成按位置标识的小段;“并发”必须区分多个文件同时传、一个文件多段同时传,以及建立多条传输通道,不能用一个开关混为一谈。 + +| 编号 | 候选能力 | 做之前要决定的边界 | +| --- | --- | --- | +| T05 | 上传与下载均使用有界流式 I/O,单文件可超过当前 2 GiB 限制 | 用 64 位大小与偏移,内存随缓冲预算而不是文件大小增长;新上限、磁盘配额、空闲超时和绝对期限单独评估,不承诺无限大小 | +| T06 | 多文件并发队列 | 全局、每主机、每会话并发数及用户可调范围;小文件不能被大文件长期饿死,一个失败不取消无关任务 | +| T07 | 单文件并行分片、乱序到达与失败片段重传 | 明确片段身份、偏移和长度、重复请求幂等、重叠冲突、已确认区间及最终完整性;不直接放松现有顺序上传契约 | +| T08 | 断点续传与双向校验 | 续传凭据绑定原用户、主机、传输和源版本;源文件变化立即拒绝拼接,结合分片校验与整文件校验,提交回复丢失后能查询实际结果而非盲目重传 | +| T09 | 限速、背压与交互公平性 | 背压指接收端处理不及时时发送端停止继续堆积;限制待发送字节、并发磁盘 I/O 和临时空间,为终端输入、LSP、取消与控制请求保留处理机会 | +| T10 | 单通道流水线或独立数据通道 | 先测吞吐、往返延迟、CPU、内存及终端响应;评估 JSON/base64 编码成本和同一字节流的队头阻塞,不预设多开 SSH 就一定更快,也不直接修改现有帧格式 | + +正确做法:一个 20 GiB 文件分片写入目标文件系统上的私有暂存文件,片段按声明的位置写入,全部校验完成并通过目标冲突检查后才发布;20 GiB 只是验收样例,不是已批准的新产品上限。 + +不要这样做:把所有分片塞进内存后再拼接;为了显示进度逐块改写用户原文件;失败后把部分文件标成成功;或让文件传输占满终端和 LSP 共用的发送队列。 + +#### 文件一致性、恢复与可选同步 + +| 编号 | 候选能力 | 做之前要决定的边界 | +| --- | --- | --- | +| T11 | 上传和下载对称的安全发布 | 默认不覆盖;显式替换也要保护目标的当前身份,下载同样先写本地临时文件;是否保留权限/时间戳及跨卷行为要可解释 | +| T12 | 崩溃恢复、空间检查与残留管理 | 检测磁盘不足、配额、写入失败与中途崩溃;清理只处理自己拥有且无人使用的暂存数据,不按年龄删除其他会话或存活进程的插件输入 | +| T13 | 文件夹差异预览与增量传输 | 先展示新增、修改、删除和冲突,再由用户确认方向;单向复制、双向同步、删除传播分别决定,不默认启用后台同步或自动删文件 | +| T14 | 传输实现复用评估 | 比较现有远端协议扩展、OpenSSH SFTP/SCP 与可选 rsync;评估目标是否安装、嵌套容器、断点与并发语义、取消、许可证及体积,不先写第二套完整传输引擎 | + +#### 终端与工具环境重载 + +“环境重载”分成三个动作:重新读取远端登录环境、让后续新进程使用新快照、让已经运行的进程采用新环境。前两者不能被包装成第三者;父进程通常不能从外部修改子进程的环境。 + +| 编号 | 候选能力 | 做之前要决定的边界 | +| --- | --- | --- | +| E01 | 手动刷新远端登录环境 | 显示来源、刷新成功/失败及新旧差异;失败保留可识别的旧快照,不假称刷新成功;缓存按主机和会话隔离 | +| E02 | 终端启动模式与环境配置选择 | 区分远端登录环境、服务进程环境、显式配置,以及登录/交互 shell 行为;默认值、适用范围、覆盖顺序和删除变量的语义需先确定 | +| E03 | 选择式传递本地环境变量 | 默认不整包传递;用户按名称显式选取,并能看到来源、接收主机、用途与生命周期;PATH、HOME、TMPDIR 等本机路径不可原样当远端配置,密钥和令牌不明文回显或写日志 | +| E04 | 环境变化对工具生效 | 刷新后使工具发现和可执行文件缓存失效;明确“仅新终端/新任务生效”与“确认后重启指定 LSP/构建提供者”,不自动结束正在运行的用户任务 | +| E05 | 已有终端的选择性重载 | 默认新建终端或明确提示重启;如评估 shell integration(由受支持 shell 主动配合的集成),只在明确允许且 shell 空闲时执行可解释的重载,不能往忙碌或运行全屏程序的终端偷偷注入 source/export 命令 | +| E06 | 项目环境与环境配置预设 | 评估复用 direnv、虚拟环境等原有工具,尊重其信任确认;区分连接、工作区、终端和单次任务作用域,不维护不生效的影子设置 | +| E07 | 代理与凭据相关变量的专门策略 | 本机 localhost 代理、SSH_AUTH_SOCK 和钥匙串不是复制字符串就能远端使用;先判断是否需要明确授权的隧道或原生转发,不自动转发凭据或重写全部 URL | + +正确做法:用户更新远端 shell 配置后,明确刷新,看到来源和差异,选择“仅后续新进程使用”,或单独确认重启某个工具;已有终端里的工作不受影响。 + +不要这样做:打开设置页就执行 profile、修改文件或把本机全部环境发到远端;刷新后只改界面缓存却声称旧终端已经生效;通过字符串拼接把变量值当 shell 代码执行。 + +#### 其他补充候选 + +| 编号 | 候选能力 | 做之前要决定的边界 | +| --- | --- | --- | +| X01 | 连接质量、重连与资源诊断 | 显示连接状态、往返延迟、传输瓶颈、远端磁盘和会话资源;区分网络中断、认证失败、服务升级与重放截断,诊断包脱敏且由用户确认导出 | +| X02 | 常驻终端与任务的恢复选择 | 重连后先列出可恢复资源,让用户选择恢复或结束;保留原主机身份,明确输出历史缺失,不自动重跑可能有副作用的命令 | +| X03 | 端口转发管理 | 明确列出端口方向、绑定地址、实际访问位置、占用与生命周期;自动发现不等于自动暴露,默认不监听公网,不绕过用户确认 | +| X04 | 通用插件部署提速与容量管理 | 评估同批小文件、同主机已验证内容的复用和预部署;仍绑定批准来源、版本、架构、信任与进程使用期,不与构建缓存或跨工作树可变状态混用 | +| X05 | 只读与危险操作保护 | 连接只读、覆盖/批量删除确认、带内容身份的废纸篓与大文件写入分别明确能力边界;客户端开关不能声称等同于服务器账户权限隔离 | +| X06 | 通用工作流与平台验收 | 保存、模型刷新、构建、报告、启动以及插件/LSP 断线取消;覆盖真实 Linux 两架构、远程 Mac、容器与 Windows 接入,不能用本机 fixture 代替 | + +#### 决定是否做时的拆分建议 + +建议先分别选择“基础双向传输”“超大文件与恢复”“并发与性能”“环境选择与刷新”“补充体验”五组,而不是一次批准全部。组内仍可逐项取舍;单文件并发、独立数据通道、双向同步、已有 shell 原地重载都可以不做,不应成为基础上传下载的前置条件。 + +如果采纳,先比较成熟上游能力,再为选中项明确契约、预算、兼容性和验收方案;实现时才更新功能矩阵及对应资源清单。未采纳的新增能力不改变当前协议保证,不自动构成 #1515 的合并条件,也不触发自动开发、排期、Issue 创建或额外 push。已有功能的安全修复与既定验证 gate 不因候选待定而被豁免。Agent 及语言专属功能不从本候选计划自动扩展。 ### SSH 连接配置界面(M2 参考) @@ -256,6 +336,9 @@ M2 接入“打开远程文件夹”时,SSH 连接的配置界面参考用户 - 服务能检查通过所有远端目标(Linux musl 两种架构、macOS 两种架构);两个 Linux 静态 release 版用 `rust-lld` 构建成功,并且都不超过 1.5 MiB 的体积上限。 - Linux CI lane(`.github/workflows/ci-remote.yml`)只在远程相关路径变化时运行,不占用 macOS 和 Windows runner。 - 后续阶段的验收标准在各自的 PR 和 Note 中补充,例如 M1 要在真实的 SSH 主机、Docker 容器和远程 Mac 上各验证一次完整流程。 +- “待评估能力候选”采纳后另定对应验收:超出 2 GiB/4 GiB 的流式传输与偏移、分片乱序/重复/缺失、源或目标中途改变、断线/重启、磁盘不足/限额/权限、取消清理、哈希一致性,以及大流量下终端和 LSP 的延迟与内存预算。这里只定义要验证的问题,不代表这些能力已有实现或测试结果。 +- 环境候选采纳后验证来源读取、差异、敏感变量保护、显式选择与取消、仅新进程生效、确认重启、忙碌终端保护、缓存失效和跨主机隔离;测试不得执行用户真实 profile 或写入用户 shell 历史。 +- 本次仅记录候选的文档变更运行 `./scripts/verify-agent-notes.sh`;未来创建测试时再加载稳定测试 Skill,并按平台执行有界的计时验证。 ## 风险 @@ -268,7 +351,9 @@ M2 接入“打开远程文件夹”时,SSH 连接的配置界面参考用户 - **确认的是“交给客户端库”**:客户端库把事件分发到订阅通道时就算收到,`last_event_seq()` 也按这个计数。应用丢弃了还没读完的句柄,那部分事件不会再补发;需要更精细控制时,按每个事件自带的 `seq` 决定从哪里续传。 - **按会话结束进程时的 pid 复用**:信号按会话 id(等于首进程的 pid)发送。会话还有成员时,这个编号不会被复用;会话一空就必须尽快忘掉它。服务每 500 毫秒检查一次只为剩余成员保留的会话,`kill` 和关闭终端在发信号前也会再检查一次。剩下的风险只在“会话刚空、检查还没跑”的这几百毫秒里,而且还需要恰好在这段时间里分配到同一个 pid。检查一次扫描整张进程表,判断所有待查的会话。 - **测试会碰到用户自己的配置**:交互式 shell 会读写用户真实的 `~/.bash_history`。开发时就发生过一次:终端测试把测试命令写进了开发者的历史记录,之后已经清理。现在所有测试终端都设置 `HISTFILE=/dev/null`;登录环境测试使用临时的 `HOME` 和固定的 `/bin/sh`。以后新增的测试也必须这样隔离。 -- **回退方式**:三个 crate 目前没有被产品代码引用。如果方向需要调整,可以整体删除,不影响现有功能。 +- **回退方式**:最初三个 crate 独立时可以整体删除;当前已有产品路径引用它们,后续调整必须按已接入的调用链迁移,不得再按“尚无消费者”整体移除。 +- **候选并非承诺**:并发分片可能增加随机磁盘 I/O、校验和编码成本,独立通道增加认证与生命周期复杂度;必须测量收益,不能预设并发数越大越快。文件夹双向同步和删除传播还会扩大误删风险,应单独确认。 +- **环境重载有副作用**:重新执行 profile 可能启动程序、等待输入或改变文件;本地变量可能含凭据或仅在本机有效的路径。必须有明确触发、边界与失败反馈,不能用“刷新”隐藏执行和传递行为。 ## 适用范围 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 60b3b168f8..f87013a0ec 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -412,8 +412,10 @@ HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平 远端构建输出、插件运行快照和分块上传同样登记在 `remote-workspace-session-state`, 没有跨工作树复制阶段。上传在目标父目录用 `.lithe-upload--` 暂存, 按连接持有,失败、断线或五分钟期限后删除;SHA-256 与架构校验只授权本次运行。 -完整插件快照位于远端 `~/.lithe-server/plugin-runs//package`,进程销毁后释放; -客户端崩溃或断线后的完整快照回收仍为 TODO。插件配置、索引等持久状态位于远端 +完整插件快照位于远端 `~/.lithe-server/plugin-runs//package`,由 `fs.createRunDirectory` +登记为会话所有,进程销毁后按同一身份释放;非驻留断线、会话关闭和空闲到期时由服务端回收。 +常驻会话短暂断线保留快照;客户端崩溃后按会话空闲期限回收。远端服务被强制杀死后的安全回收仍为 TODO, +不能按目录年龄删除其他会话或仍有进程使用的输入,也不接管旧版未登记目录。插件配置、索引等持久状态位于远端 `~/.lithe-server/state/plugins////workspaces/`, 由该工作区和提供者持有,构建临时输出按运行清理。异平台包下载复用市场下载器, 在平台临时目录解压,传输完成或失败后删除;校验来源包括市场归档 SHA-256、安装来源、 diff --git a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift index 8a2cd006a3..61bcd62bd8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift @@ -37,7 +37,7 @@ struct MacRemotePluginPackageDeployer: RemotePluginPackageDeploying { $0.descriptor.id.rawValue == pluginID && $0.packageURL.standardizedFileURL == packageURL.standardizedFileURL }) else { throw failure("plugin.remote.unapproved", L10n.string("The remote plugin package is not part of the approved installation.")) } let facts = try await host.pluginHost(session: session) - guard let platform = facts.platform, facts.features.contains("upload") else { + guard let platform = facts.platform, facts.features.contains("upload"), facts.features.contains("runDirectories") else { throw failure("plugin.remote.unsupported", L10n.string("This host cannot receive a compatible plugin package. Update the remote server.")) } // Revalidate the original approval before using its source or programs. @@ -116,29 +116,17 @@ struct MacRemotePluginPackageDeployer: RemotePluginPackageDeploying { private func transfer(_ snapshot: LocalSnapshot, session: String, facts: RemotePluginHost, localCleanup: URL?) async throws -> RemoteDeployedPluginPackage { - var remoteRoot: String? + let directoryID = UUID().uuidString do { - guard let home = facts.homeDirectory, home.hasPrefix("/"), !home.contains("\0"), - URL(fileURLWithPath: home).standardizedFileURL.path == home else { + // Know the identity before allocation: a lost response or cancelled + // transfer must still release the exact session-owned directory. + let allocated = try await host.pluginRequest("fs.createRunDirectory", params: .object([ + "directoryId": .string(directoryID)]), session: session) + guard case .object(let value) = allocated, case .string(let root) = value["path"], + root.hasPrefix("/"), !root.contains("\0"), + URL(fileURLWithPath: root).standardizedFileURL.path == root else { throw CocoaError(.fileReadCorruptFile) } - let parent = home + "/.lithe-server/plugin-runs" - _ = try await host.pluginRequest("fs.createDir", params: .object([ - "path": .string(parent), "recursive": .bool(true), "mode": .integer(0o700)]), session: session) - for directory in [home + "/.lithe-server", parent] { - let metadata = try await host.pluginRequest("fs.stat", params: .object(["path": .string(directory)]), session: session) - guard case .object(let value) = metadata, value["kind"] == .string("directory"), - case .integer(let mode) = value["mode"], mode & 0o077 == 0 else { - throw CocoaError(.fileWriteNoPermission) - } - } - let resolved = try await host.pluginRequest("fs.realPath", params: .object(["path": .string(parent)]), session: session) - guard case .object(let value) = resolved, case .string(let canonical) = value["path"] else { throw CocoaError(.fileReadCorruptFile) } - // Keep the exact owned path before allocation: cancellation after - // the host creates it still has a known cleanup destination. - let root = canonical + "/" + UUID().uuidString - remoteRoot = root - _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(root), "mode": .integer(0o700)]), session: session) let package = root + "/package" _ = try await host.pluginRequest("fs.createDir", params: .object(["path": .string(package), "mode": .integer(0o700)]), session: session) for entry in snapshot.entries { @@ -158,23 +146,23 @@ struct MacRemotePluginPackageDeployer: RemotePluginPackageDeploying { if let localCleanup { await removeLocal(localCleanup) } return RemoteDeployedPluginPackage(packageURL: URL(fileURLWithPath: package, isDirectory: true), manifest: snapshot.descriptor.normalizedManifest, architecture: facts.templateArchitecture) { - await release(root, session: session) + await release(directoryID, session: session) } } catch { - if let remoteRoot { await release(remoteRoot, session: session) } + await release(directoryID, session: session) if let localCleanup { await removeLocal(localCleanup) } throw error } } - private func release(_ root: String, session: String) async { + private func release(_ directoryID: String, session: String) async { // A cancelled build still needs an uncancelled cleanup operation on its // original host. Directories stay private and owner-writable for removal; // runtime state is always outside this snapshot and files are read-only. let cleanup = Task.detached { do { do { - _ = try await host.pluginRequest("fs.remove", params: .object(["path": .string(root), "recursive": .bool(true)]), session: session) + _ = try await host.pluginRequest("fs.releaseRunDirectory", params: .object(["directoryId": .string(directoryID)]), session: session) } catch let error as RemoteProbeFailure where error.code == "workspace_not_found" { // Cancellation may have won before the allocation. } diff --git a/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift b/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift index 6586222eae..1b7d8c33d5 100644 --- a/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift +++ b/macos/Tests/LitheTests/RemotePluginDeploymentTests.swift @@ -23,6 +23,7 @@ struct RemotePluginDeploymentTests { await result.release() #expect(host.removals.count == 1) #expect(host.removals.first?.session == "original") + #expect(host.removals.first?.directoryID == host.allocations.first) #expect(try PluginPackageSignature.contentDigest(packageAt: fixture.package.packageURL) == before) } @@ -60,7 +61,7 @@ struct RemotePluginDeploymentTests { let fixture = try DeploymentFixture() defer { fixture.remove() } let host = DeploymentHostFixture() - host.facts = .init(os: "linux", arch: "aarch64", homeDirectory: "/host", features: ["upload"]) + host.facts = .init(os: "linux", arch: "aarch64", homeDirectory: "/host", features: ["upload", "runDirectories"]) let deployer = fixture.deployer(host) await #expect(throws: DeclarativePluginRejection.self) { _ = try await deployer.deploy(pluginID: fixture.id, packageURL: fixture.package.packageURL, session: "linux") @@ -73,6 +74,39 @@ struct RemotePluginDeploymentTests { } #expect(host.uploads.isEmpty) #expect(host.removals.isEmpty) + #expect(host.allocations.isEmpty) + } + + @Test + func lostAllocationResponseReleasesTheIdentityKnownBeforeTheRequest() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + host.losesAllocationResponse = true + await #expect(throws: CocoaError.self) { + _ = try await fixture.deployer(host).deploy(pluginID: fixture.id, + packageURL: fixture.package.packageURL, session: "lost-response") + } + #expect(host.allocations.count == 1) + #expect(host.removals.count == 1) + #expect(host.removals.first?.directoryID == host.allocations.first) + #expect(host.removals.first?.session == "lost-response") + #expect(host.uploads.isEmpty) + } + + @Test + func olderHostWithoutSessionOwnedDirectoriesNeverAllocatesOrUploads() async throws { + let fixture = try DeploymentFixture() + defer { fixture.remove() } + let host = DeploymentHostFixture() + host.facts = .init(os: "macos", arch: DeploymentHostFixture.currentHost.arch, + homeDirectory: "/host", features: ["upload"]) + await #expect(throws: DeclarativePluginRejection.self) { + _ = try await fixture.deployer(host).deploy(pluginID: fixture.id, + packageURL: fixture.package.packageURL, session: "older-host") + } + #expect(host.uploads.isEmpty) + #expect(host.removals.isEmpty) } @Test @@ -127,29 +161,38 @@ private struct NoDeploymentNetwork: MacPluginMarketplaceTransport { private final class DeploymentHostFixture: RemotePluginFileAccess, @unchecked Sendable { struct Upload { let target: String; let mode: UInt32 } - struct Removal { let session: String } + struct Removal { let session: String; let directoryID: String } static let currentHost = RemotePluginHost(os: "macos", arch: MacPluginLanguageServerLaunchResolver.hostArchitecture == "arm64" ? "aarch64" : "x86_64", - homeDirectory: "/host", features: ["upload"]) + homeDirectory: "/host", features: ["upload", "runDirectories"]) private let lock = NSLock() // Scripted outcomes change only between awaited calls. var facts = currentHost var failsUpload = false var blocksUpload = false + var losesAllocationResponse = false private var sent: [Upload] = [] private var removed: [Removal] = [] private var cancelledCleanup = false + private var allocated: [String] = [] let started = TestGate() let release = TestGate() var uploads: [Upload] { lock.withLock { sent } } var removals: [Removal] { lock.withLock { removed } } var cleanupWasCancelled: Bool { lock.withLock { cancelledCleanup } } + var allocations: [String] { lock.withLock { allocated } } func pluginHost(session: String) async throws -> RemotePluginHost { facts } func pluginRequest(_ method: String, params: ToolingJSONValue, session: String) async throws -> ToolingJSONValue { if method == "fs.stat" { return .object(["kind": .string("directory"), "mode": .integer(0o700)]) } - if method == "fs.realPath" { return .object(["path": .string("/host/.lithe-server/plugin-runs")]) } - if method == "fs.remove" { - lock.withLock { removed.append(.init(session: session)); cancelledCleanup = Task.isCancelled } + if method == "fs.createRunDirectory", case .object(let values) = params, + case .string(let id) = values["directoryId"] { + lock.withLock { allocated.append(id) } + if losesAllocationResponse { throw CocoaError(.fileReadUnknown) } + return .object(["path": .string("/host/.lithe-server/plugin-runs/" + id)]) + } + if method == "fs.releaseRunDirectory", case .object(let values) = params, + case .string(let id) = values["directoryId"] { + lock.withLock { removed.append(.init(session: session, directoryID: id)); cancelledCleanup = Task.isCancelled } } return .null } diff --git a/rust/lithe-core/src/remote/workspace_host.rs b/rust/lithe-core/src/remote/workspace_host.rs index 1998032c48..20594ef1cd 100644 --- a/rust/lithe-core/src/remote/workspace_host.rs +++ b/rust/lithe-core/src/remote/workspace_host.rs @@ -97,6 +97,8 @@ fn allowed_method(method: &str) -> bool { | methods::FS_UPLOAD_CHUNK | methods::FS_UPLOAD_COMMIT | methods::FS_UPLOAD_ABORT + | methods::FS_CREATE_RUN_DIRECTORY + | methods::FS_RELEASE_RUN_DIRECTORY | methods::ENVIRONMENT_RESOLVE | methods::PROCESS_WRITE_STDIN | methods::PROCESS_CLOSE_STDIN @@ -546,6 +548,8 @@ mod request_tests { } for method in [ methods::FS_STAT, + methods::FS_CREATE_RUN_DIRECTORY, + methods::FS_RELEASE_RUN_DIRECTORY, methods::ENVIRONMENT_RESOLVE, methods::PROCESS_WRITE_STDIN, methods::PROCESS_KILL, diff --git a/rust/lithe-remote-protocol/src/methods.rs b/rust/lithe-remote-protocol/src/methods.rs index 863ccd34a7..712a3474eb 100644 --- a/rust/lithe-remote-protocol/src/methods.rs +++ b/rust/lithe-remote-protocol/src/methods.rs @@ -41,6 +41,16 @@ pub const FS_UPLOAD_START: &str = "fs.uploadStart"; pub const FS_UPLOAD_CHUNK: &str = "fs.uploadChunk"; pub const FS_UPLOAD_COMMIT: &str = "fs.uploadCommit"; pub const FS_UPLOAD_ABORT: &str = "fs.uploadAbort"; +/// Private runtime snapshots owned by the session, not by a live connection. +pub const FS_CREATE_RUN_DIRECTORY: &str = "fs.createRunDirectory"; +pub const FS_RELEASE_RUN_DIRECTORY: &str = "fs.releaseRunDirectory"; + +/// Caller-generated opaque identity, known before allocation can be cancelled. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RunDirectoryParams { + pub directory_id: String, +} /// Maximum decoded chunk, independent of the full file size. pub const MAX_UPLOAD_CHUNK_BYTES: usize = 256 * 1024; @@ -269,6 +279,8 @@ pub enum Feature { Filesystem, /// Bounded staged uploads with SHA-256 and atomic no-replace publication. Upload, + /// Session-owned private run directories reclaimed when the session ends. + RunDirectories, /// `walk.*` methods and events. Walk, /// `watch.*` methods and events. diff --git a/rust/lithe-remote-server/src/connection.rs b/rust/lithe-remote-server/src/connection.rs index 529e66992e..1f9ce64443 100644 --- a/rust/lithe-remote-server/src/connection.rs +++ b/rust/lithe-remote-server/src/connection.rs @@ -229,6 +229,9 @@ fn handle(context: &Context, method: &str, params: Value) -> Result { session.uploads.handle(context, method, params) } + methods::FS_CREATE_RUN_DIRECTORY | methods::FS_RELEASE_RUN_DIRECTORY => { + session.run_directories.handle(context, method, params) + } method if method.starts_with("fs.") => fs::handle(method, params), method if method.starts_with("walk.") => session.walks.handle(context, method, params), method if method.starts_with("watch.") => session.watches.handle(context, method, params), @@ -306,6 +309,28 @@ enum ResourceOrder { impl ResourceOrder { fn prepare(session: &Arc, method: &str, params: &Value) -> Result { + if matches!( + method, + methods::FS_CREATE_RUN_DIRECTORY | methods::FS_RELEASE_RUN_DIRECTORY + ) { + let parsed: methods::RunDirectoryParams = parse(params.clone())?; + crate::run_directory::validate(&parsed)?; + return if method == methods::FS_CREATE_RUN_DIRECTORY { + Ok(Self::Start { + guard: session + .starts + .reserve("runDirectory", &parsed.directory_id)?, + close_method: methods::FS_RELEASE_RUN_DIRECTORY, + close_params: params.clone(), + }) + } else { + Ok(session + .starts + .cancel("runDirectory", &parsed.directory_id, params) + .map(Self::After) + .unwrap_or(Self::Independent)) + }; + } let (family, field, start) = match method { methods::PROCESS_SPAWN => ("process", "processId", true), methods::PROCESS_KILL => ("process", "processId", false), @@ -383,6 +408,7 @@ impl ResourceOrder { "lithe-remote-server: cancelled start cleanup failed: {:?}", error.code ); + guard.cleanup_failed(error); } } } diff --git a/rust/lithe-remote-server/src/connection/tests.rs b/rust/lithe-remote-server/src/connection/tests.rs index 6f6f703393..caa3f7a8c0 100644 --- a/rust/lithe-remote-server/src/connection/tests.rs +++ b/rust/lithe-remote-server/src/connection/tests.rs @@ -279,3 +279,48 @@ impl Drop for Cleanup { self.0.terminate_all(); } } + +#[test] +fn run_directory_cancellation_is_reserved_before_allocation_is_scheduled() { + let session = Session::new(true).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"directoryId": "pending-run"}); + let create = + ResourceOrder::prepare(&session, methods::FS_CREATE_RUN_DIRECTORY, ¶ms).unwrap(); + assert!(ResourceOrder::prepare( + &session, + methods::FS_RELEASE_RUN_DIRECTORY, + &json!({"directoryId": "pending-run", "unexpected": true}) + ) + .is_err()); + let close = + ResourceOrder::prepare(&session, methods::FS_RELEASE_RUN_DIRECTORY, ¶ms).unwrap(); + assert!(close.wait(Duration::ZERO).is_err()); + assert!(ResourceOrder::prepare(&session, methods::FS_CREATE_RUN_DIRECTORY, ¶ms).is_err()); + // A BUSY or cancelled allocation worker drops its reservation; the close + // waiter completes without needing to access a user-owned home directory. + drop(create); + close.wait(Duration::ZERO).unwrap(); + ResourceOrder::prepare(&session, methods::FS_CREATE_RUN_DIRECTORY, ¶ms).unwrap(); +} + +#[test] +fn pending_close_reports_the_creators_cleanup_failure_instead_of_success() { + let session = Session::new(true).unwrap(); + let _cleanup = Cleanup(Arc::clone(&session)); + let params = json!({"directoryId": "failed-cleanup"}); + let creation = + ResourceOrder::prepare(&session, methods::FS_CREATE_RUN_DIRECTORY, ¶ms).unwrap(); + let close = + ResourceOrder::prepare(&session, methods::FS_RELEASE_RUN_DIRECTORY, ¶ms).unwrap(); + let ResourceOrder::Start { guard, .. } = &creation else { + panic!("expected start reservation"); + }; + // Deterministically model a filesystem cleanup failure without changing + // user permissions or relying on a particular platform's I/O error. + let failure = RemoteError::new(ErrorCode::PermissionDenied, "cleanup denied"); + guard.cleanup_failed(failure.clone()); + drop(creation); + assert_eq!(close.wait(Duration::ZERO), Err(failure)); + ResourceOrder::prepare(&session, methods::FS_CREATE_RUN_DIRECTORY, ¶ms).unwrap(); +} diff --git a/rust/lithe-remote-server/src/lib.rs b/rust/lithe-remote-server/src/lib.rs index 46a8a8134f..5f9d58254e 100644 --- a/rust/lithe-remote-server/src/lib.rs +++ b/rust/lithe-remote-server/src/lib.rs @@ -29,6 +29,7 @@ mod platform; mod ports; mod process; mod resources; +mod run_directory; mod session; mod spawner; mod terminal; diff --git a/rust/lithe-remote-server/src/platform/mod.rs b/rust/lithe-remote-server/src/platform/mod.rs index 7b07e51154..656831292e 100644 --- a/rust/lithe-remote-server/src/platform/mod.rs +++ b/rust/lithe-remote-server/src/platform/mod.rs @@ -52,6 +52,7 @@ pub(crate) fn host_capabilities() -> HostCapabilities { let mut features = vec![ Feature::Filesystem, Feature::Upload, + Feature::RunDirectories, Feature::Walk, Feature::Watch, Feature::Process, diff --git a/rust/lithe-remote-server/src/resources.rs b/rust/lithe-remote-server/src/resources.rs index d1d3dd914b..5a714b6fdb 100644 --- a/rust/lithe-remote-server/src/resources.rs +++ b/rust/lithe-remote-server/src/resources.rs @@ -26,7 +26,7 @@ impl IdWatermark { /// A later close must wait for publication (or failure), not race an empty table. #[derive(Default)] pub(crate) struct PendingStarts { - entries: Mutex>>, + entries: Mutex>>, } #[derive(Default)] @@ -34,6 +34,7 @@ pub(crate) struct StartCompletion { finished: Mutex, changed: Condvar, close_params: Mutex>, + cleanup_failure: Mutex>, } impl StartCompletion { @@ -54,7 +55,10 @@ impl StartCompletion { .unwrap_or_else(|error| error.into_inner()) .0; } - Ok(()) + match crate::lock(&self.cleanup_failure).clone() { + Some(error) => Err(error), + None => Ok(()), + } } } @@ -62,10 +66,10 @@ impl PendingStarts { pub(crate) fn reserve( self: &Arc, family: &'static str, - id: u64, + id: impl ToString, ) -> Result { let mut entries = crate::lock(&self.entries); - let key = (family, id); + let key = (family, id.to_string()); if entries.contains_key(&key) { return Err(RemoteError::new( ErrorCode::ResourceIdInUse, @@ -73,7 +77,7 @@ impl PendingStarts { )); } let completion = Arc::new(StartCompletion::default()); - entries.insert(key, Arc::clone(&completion)); + entries.insert(key.clone(), Arc::clone(&completion)); Ok(StartGuard { starts: Arc::clone(self), key, @@ -84,11 +88,11 @@ impl PendingStarts { pub(crate) fn cancel( &self, family: &'static str, - id: u64, + id: impl ToString, params: &Value, ) -> Option> { let entries = crate::lock(&self.entries); - let completion = entries.get(&(family, id))?; + let completion = entries.get(&(family, id.to_string()))?; let mut close = crate::lock(&completion.close_params); // Multiple cancellation attempts may strengthen, but never downgrade, // an explicit force kill while the child is still being created. @@ -106,11 +110,16 @@ impl PendingStarts { /// worker scheduling failure, or panic. No lifecycle lock covers host I/O. pub(crate) struct StartGuard { starts: Arc, - key: (&'static str, u64), + key: (&'static str, String), completion: Arc, } impl StartGuard { + /// Preserve creator-owned cleanup errors for close requests waiting on it. + pub(crate) fn cleanup_failed(&self, error: RemoteError) { + *crate::lock(&self.completion.cleanup_failure) = Some(error); + } + /// After publication, atomically transfers close ownership either to the /// creating worker or to a subsequent ordinary close request. pub(crate) fn take_cancellation(&self) -> Option { diff --git a/rust/lithe-remote-server/src/run_directory.rs b/rust/lithe-remote-server/src/run_directory.rs new file mode 100644 index 0000000000..f2a8962eb9 --- /dev/null +++ b/rust/lithe-remote-server/src/run_directory.rs @@ -0,0 +1,188 @@ +//! Session-owned private directories for deployed runtime inputs. +//! Persistent disconnects retain them; explicit release or session termination +//! removes them, even if the desktop vanished before receiving allocation. + +use crate::connection::Context; +use crate::fs::io_error; +use crate::{lock, parse, platform}; +use lithe_remote_protocol::methods::{self, RunDirectoryParams}; +use lithe_remote_protocol::{ErrorCode, RemoteError}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::{DirBuilderExt, MetadataExt}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +const MAX_DIRECTORIES: usize = 256; + +pub(crate) struct RunDirectories { + home: Option, + entries: Mutex>>, +} + +struct Directory { + path: PathBuf, + allocated: AtomicBool, + removing: Mutex<()>, +} + +impl Drop for Directory { + fn drop(&mut self) { + if let Err(error) = self.remove() { + eprintln!("remote run directory cleanup failed: {:?}", error.kind()); + } + } +} + +impl Directory { + fn remove(&self) -> std::io::Result<()> { + if self.allocated.load(Ordering::Acquire) { + match fs::remove_dir_all(&self.path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + self.allocated.store(false, Ordering::Release); + } + Ok(()) + } +} + +pub(crate) fn validate(params: &RunDirectoryParams) -> Result<(), RemoteError> { + if params.directory_id.is_empty() + || params.directory_id.len() > 64 + || !params + .directory_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid run directory identity", + )); + } + Ok(()) +} + +impl RunDirectories { + pub(crate) fn new(home: Option) -> Self { + Self { + home, + entries: Mutex::default(), + } + } + + pub(crate) fn handle( + &self, + context: &Context, + method: &str, + params: Value, + ) -> Result { + let params: RunDirectoryParams = parse(params)?; + validate(¶ms)?; + if method == methods::FS_RELEASE_RUN_DIRECTORY { + // Keep failed cleanup registered for retry. Recursive deletion must + // not hold the registry lock or report success on a failed removal. + let directory = lock(&self.entries).get(¶ms.directory_id).cloned(); + let Some(directory) = directory else { + return Ok(Value::Null); + }; + let _removing = directory.removing.try_lock().map_err(|_| { + RemoteError::new(ErrorCode::Busy, "run directory removal in progress") + })?; + directory + .remove() + .map_err(|error| io_error(error, "run directory"))?; + self.forget(¶ms.directory_id, &directory); + return Ok(Value::Null); + } + if context.session.is_ending() + || (!context.session.persistent && context.closed.load(Ordering::Acquire)) + { + return Err(RemoteError::new(ErrorCode::NotFound, "session ended")); + } + let home = self + .home + .as_deref() + .ok_or_else(|| RemoteError::new(ErrorCode::Unsupported, "host home unavailable"))?; + let home = crate::fs::host_path(home)?; + let base = home.join(".lithe-server"); + private_directory(&base)?; + let parent = base.join("plugin-runs"); + private_directory(&parent)?; + let parent = fs::canonicalize(parent).map_err(|error| io_error(error, "run parent"))?; + let directory = Arc::new(Directory { + path: parent.join(¶ms.directory_id), + allocated: AtomicBool::new(false), + removing: Mutex::new(()), + }); + { + let mut entries = lock(&self.entries); + if context.session.is_ending() { + return Err(RemoteError::new(ErrorCode::NotFound, "session ended")); + } + if entries.contains_key(¶ms.directory_id) { + return Err(RemoteError::new( + ErrorCode::ResourceIdInUse, + "run directory identity is active", + )); + } + if entries.len() >= MAX_DIRECTORIES { + return Err(RemoteError::new( + ErrorCode::Busy, + "too many run directories", + )); + } + entries.insert(params.directory_id.clone(), Arc::clone(&directory)); + } + // Never adopt a pre-existing path, including one from another session. + // The worker's Arc owns late allocation if shutdown drains the registry. + if let Err(error) = fs::DirBuilder::new().mode(0o700).create(&directory.path) { + self.forget(¶ms.directory_id, &directory); + return Err(io_error(error, "run directory")); + } + directory.allocated.store(true, Ordering::Release); + Ok(json!({"path": directory.path})) + } + + pub(crate) fn clear(&self) { + let removed = std::mem::take(&mut *lock(&self.entries)); + drop(removed); + } + + fn forget(&self, id: &str, directory: &Arc) { + let removed = { + let mut entries = lock(&self.entries); + if entries + .get(id) + .is_some_and(|entry| Arc::ptr_eq(entry, directory)) + { + entries.remove(id) + } else { + None + } + }; + drop(removed); + } +} + +fn private_directory(path: &Path) -> Result<(), RemoteError> { + match fs::DirBuilder::new().mode(0o700).create(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(io_error(error, "run parent")), + } + let metadata = fs::symlink_metadata(path).map_err(|error| io_error(error, "run parent"))?; + if !metadata.is_dir() + || metadata.uid() != platform::current_uid() + || metadata.mode() & 0o077 != 0 + { + return Err(RemoteError::new( + ErrorCode::PermissionDenied, + "run parent must be a private directory owned by the host user", + )); + } + Ok(()) +} diff --git a/rust/lithe-remote-server/src/session.rs b/rust/lithe-remote-server/src/session.rs index b1a4cd5ef3..06642380d4 100644 --- a/rust/lithe-remote-server/src/session.rs +++ b/rust/lithe-remote-server/src/session.rs @@ -63,6 +63,7 @@ pub(crate) struct Session { pub(crate) tunnels: TunnelTable, pub(crate) walks: WalkTable, pub(crate) uploads: crate::upload::UploadTable, + pub(crate) run_directories: crate::run_directory::RunDirectories, /// Requests running on worker threads, drained before the session ends. pub(crate) workers: InFlight, pub(crate) starts: Arc, @@ -93,6 +94,9 @@ impl Session { tunnels: TunnelTable::new(), walks: WalkTable::new(), uploads: crate::upload::UploadTable::default(), + run_directories: crate::run_directory::RunDirectories::new( + crate::platform::current_account().home_directory, + ), workers: InFlight::default(), starts: Arc::default(), ending: AtomicBool::new(false), @@ -171,5 +175,7 @@ impl Session { eprintln!("lithe-remote-server: {unfinished} requests did not finish"); } self.uploads.clear(); + // Processes must be stopped before their read-only runtime inputs go. + self.run_directories.clear(); } } diff --git a/rust/lithe-remote-server/src/upload.rs b/rust/lithe-remote-server/src/upload.rs index acaa2c64c5..770bf7aeb3 100644 --- a/rust/lithe-remote-server/src/upload.rs +++ b/rust/lithe-remote-server/src/upload.rs @@ -13,8 +13,8 @@ use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, TryLockError}; use std::time::{Duration, Instant}; const MAX_UPLOADS: usize = 4; @@ -22,11 +22,19 @@ const LIFETIME: Duration = Duration::from_secs(5 * 60); static SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Default)] -pub(crate) struct UploadTable(Mutex>); +pub(crate) struct UploadTable(Mutex>>); + +/// Registry operations never wait for file I/O. A removed slot is marked first; +/// its current worker keeps ownership until the staging inode can be removed. +struct Slot { + owner: u64, + expires: Instant, + cancelled: AtomicBool, + upload: Mutex>, +} /// Owns one private staging inode until publication or cleanup. struct Upload { - owner: u64, file: File, staging: PathBuf, target: PathBuf, @@ -35,7 +43,6 @@ struct Upload { expected: String, mode: u32, hash: Sha256, - expires: Instant, } impl Drop for Upload { @@ -55,75 +62,44 @@ impl UploadTable { method: &str, params: Value, ) -> Result { - let mut uploads = lock(&self.0); - if context.closed.load(Ordering::Acquire) { - return Err(RemoteError::new( - ErrorCode::NotFound, - "upload connection closed", - )); + self.expire(); + if context.closed.load(Ordering::Acquire) || context.session.is_ending() { + return Err(not_found()); } - uploads.retain(|_, upload| upload.expires > Instant::now()); if method == methods::FS_UPLOAD_START { - let params: UploadStartParams = parse(params)?; - if uploads.len() >= MAX_UPLOADS { - return Err(RemoteError::new(ErrorCode::Busy, "too many staged uploads")); - } - if params.size > methods::MAX_UPLOAD_BYTES - || params.mode > 0o777 - || params.sha256.len() != 64 - || !params.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err(RemoteError::new( - ErrorCode::InvalidRequest, - "invalid upload size, digest or mode", - )); - } - let target = host_path(¶ms.path)?; - let parent = target.parent().ok_or_else(|| { - RemoteError::new(ErrorCode::InvalidPath, "cannot upload to the root") - })?; - let id = SEQUENCE.fetch_add(1, Ordering::Relaxed); - let staging = parent.join(format!(".lithe-upload-{}-{id}", std::process::id())); - let file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&staging) - .map_err(|error| io_error(error, ¶ms.path))?; - uploads.insert( - id, - Upload { - owner: context.connection, - file, - staging, - target, - size: params.size, - received: 0, - expected: params.sha256.to_ascii_lowercase(), - mode: params.mode, - hash: Sha256::new(), - expires: Instant::now() + LIFETIME, - }, - ); - return Ok(json!({"uploadId": id})); + return self.start(context, parse(params)?); } let id = params .get("uploadId") .and_then(Value::as_u64) .ok_or_else(|| RemoteError::new(ErrorCode::InvalidRequest, "missing uploadId"))?; - if uploads + let slot = lock(&self.0) .get(&id) - .is_none_or(|upload| upload.owner != context.connection) - { - return Err(RemoteError::new( - ErrorCode::NotFound, - "staged upload not found", - )); + .cloned() + .filter(|slot| slot.owner == context.connection) + .ok_or_else(not_found)?; + if method == methods::FS_UPLOAD_ABORT { + let _: UploadParams = parse(params)?; + self.remove(id, &slot); + return Ok(Value::Null); } + let mut state = match slot.upload.try_lock() { + Ok(state) => state, + Err(TryLockError::Poisoned(error)) => error.into_inner(), + Err(TryLockError::WouldBlock) => { + return Err(RemoteError::new( + ErrorCode::Busy, + "upload operation in progress", + )) + } + }; + if slot.cancelled.load(Ordering::Acquire) { + return Err(not_found()); + } + let upload = state.as_mut().ok_or_else(not_found)?; match method { methods::FS_UPLOAD_CHUNK => { let params: UploadChunkParams = parse(params)?; - let upload = uploads.get_mut(&id).unwrap(); if params.data.len() > methods::MAX_UPLOAD_CHUNK_BYTES.div_ceil(3) * 4 { return Err(RemoteError::new( ErrorCode::InvalidRequest, @@ -148,41 +124,50 @@ impl UploadTable { )); } if let Err(error) = upload.file.write_all(&bytes) { - uploads.remove(&id); + self.remove(id, &slot); + state.take(); return Err(io_error(error, "staged upload")); } upload.hash.update(&bytes); upload.received += bytes.len() as u64; Ok(json!({"offset": upload.received})) } - methods::FS_UPLOAD_ABORT => { - let _: UploadParams = parse(params)?; - uploads.remove(&id); - Ok(Value::Null) - } methods::FS_UPLOAD_COMMIT => { let _: UploadParams = parse(params)?; - let upload = uploads.remove(&id).unwrap(); - let digest = upload - .hash - .clone() - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - if upload.received != upload.size || digest != upload.expected { - return Err(RemoteError::new( - ErrorCode::Conflict, - "upload integrity check failed", - )); - } - upload - .file - .set_permissions(fs::Permissions::from_mode(upload.mode)) - .and_then(|()| upload.file.sync_all()) - .and_then(|()| platform::rename_no_replace(&upload.staging, &upload.target)) - .map_err(|error| io_error(error, "staged upload"))?; - Ok(json!({"size": upload.size, "sha256": digest})) + let upload = state.take().unwrap(); + // Keep the slot discoverable during sync so disconnect/expiry + // can mark it without waiting on the per-upload mutex. + let result = (|| { + let digest = upload + .hash + .clone() + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + if upload.received != upload.size || digest != upload.expected { + return Err(RemoteError::new( + ErrorCode::Conflict, + "upload integrity check failed", + )); + } + upload + .file + .set_permissions(fs::Permissions::from_mode(upload.mode)) + .and_then(|()| upload.file.sync_all()) + .map_err(|error| io_error(error, "staged upload"))?; + if slot.cancelled.load(Ordering::Acquire) + || context.closed.load(Ordering::Acquire) + || context.session.is_ending() + { + return Err(not_found()); + } + platform::rename_no_replace(&upload.staging, &upload.target) + .map_err(|error| io_error(error, "staged upload"))?; + Ok(json!({"size": upload.size, "sha256": digest})) + })(); + self.remove(id, &slot); + result } _ => Err(RemoteError::new( ErrorCode::UnknownMethod, @@ -191,13 +176,112 @@ impl UploadTable { } } + fn start(&self, context: &Context, params: UploadStartParams) -> Result { + if params.size > methods::MAX_UPLOAD_BYTES + || params.mode > 0o777 + || params.sha256.len() != 64 + || !params.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(RemoteError::new( + ErrorCode::InvalidRequest, + "invalid upload size, digest or mode", + )); + } + let target = host_path(¶ms.path)?; + let parent = target + .parent() + .ok_or_else(|| RemoteError::new(ErrorCode::InvalidPath, "cannot upload to the root"))?; + let id = SEQUENCE.fetch_add(1, Ordering::Relaxed); + let slot = Arc::new(Slot { + owner: context.connection, + expires: Instant::now() + LIFETIME, + cancelled: AtomicBool::new(false), + upload: Mutex::new(None), + }); + { + let mut entries = lock(&self.0); + if context.closed.load(Ordering::Acquire) || context.session.is_ending() { + return Err(not_found()); + } + if entries.len() >= MAX_UPLOADS { + return Err(RemoteError::new(ErrorCode::Busy, "too many staged uploads")); + } + entries.insert(id, Arc::clone(&slot)); + } + let staging = parent.join(format!(".lithe-upload-{}-{id}", std::process::id())); + let result = (|| { + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staging) + .map_err(|error| io_error(error, ¶ms.path))?; + let upload = Upload { + file, + staging, + target, + size: params.size, + received: 0, + expected: params.sha256.to_ascii_lowercase(), + mode: params.mode, + hash: Sha256::new(), + }; + if slot.cancelled.load(Ordering::Acquire) + || context.closed.load(Ordering::Acquire) + || context.session.is_ending() + { + return Err(not_found()); + } + *lock(&slot.upload) = Some(upload); + Ok(json!({"uploadId": id})) + })(); + if result.is_err() { + self.remove(id, &slot); + } + result + } + + fn remove(&self, id: u64, slot: &Arc) { + slot.cancelled.store(true, Ordering::Release); + let removed = lock(&self.0).remove(&id); + drop(removed); + } + + fn retain(&self, keep: impl Fn(&Slot) -> bool) { + let removed = { + let mut entries = lock(&self.0); + let ids: Vec<_> = entries + .iter() + .filter(|(_, slot)| !keep(slot)) + .map(|(id, _)| *id) + .collect(); + ids.into_iter() + .filter_map(|id| { + let slot = entries.remove(&id)?; + slot.cancelled.store(true, Ordering::Release); + Some(slot) + }) + .collect::>() + }; + // Closing files and removing staging names must not hold the table lock. + drop(removed); + } + pub(crate) fn release(&self, owner: u64) { - lock(&self.0).retain(|_, upload| upload.owner != owner); + self.retain(|slot| slot.owner != owner); } pub(crate) fn expire(&self) { - lock(&self.0).retain(|_, upload| upload.expires > Instant::now()); + let now = Instant::now(); + self.retain(|slot| slot.expires > now); } pub(crate) fn clear(&self) { - lock(&self.0).clear(); + self.retain(|_| false); } } + +fn not_found() -> RemoteError { + RemoteError::new(ErrorCode::NotFound, "staged upload not found") +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-remote-server/src/upload/tests.rs b/rust/lithe-remote-server/src/upload/tests.rs new file mode 100644 index 0000000000..53db81cc13 --- /dev/null +++ b/rust/lithe-remote-server/src/upload/tests.rs @@ -0,0 +1,103 @@ +//! Upload isolation and cancellation without relying on slow disks or sleeps. + +use super::*; +use crate::session::Session; + +#[test] +fn busy_file_does_not_block_other_uploads_or_connection_cleanup() { + let session = Session::new(true).unwrap(); + let context = Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::default(), + }; + let directory = crate::fs::handle( + methods::FS_TEMP_DIRECTORY, + json!({"prefix": "lithe-upload-lock-test"}), + ) + .unwrap(); + let root = PathBuf::from(directory["path"].as_str().unwrap()); + let _cleanup = Cleanup(Arc::clone(&session), root.clone()); + let start = |name: &str| { + session.uploads.handle(&context, methods::FS_UPLOAD_START, + json!({"path": root.join(name), "size": 1, "sha256": format!("{:x}", Sha256::digest(b"x")), "mode": 0o400})).unwrap()["uploadId"].as_u64().unwrap() + }; + let first = start("first"); + let second = start("second"); + let slot = lock(&session.uploads.0).get(&first).cloned().unwrap(); + // The per-file critical section represents a worker stalled in file I/O. + // Other operations must finish while it remains held, on this same thread. + let busy = lock(&slot.upload); + let chunk = |id| json!({"uploadId": id, "offset": 0, "data": methods::encode_bytes(b"x")}); + assert_eq!( + session + .uploads + .handle(&context, methods::FS_UPLOAD_CHUNK, chunk(first)) + .unwrap_err() + .code, + ErrorCode::Busy + ); + session + .uploads + .handle(&context, methods::FS_UPLOAD_CHUNK, chunk(second)) + .unwrap(); + session + .uploads + .handle( + &context, + methods::FS_UPLOAD_COMMIT, + json!({"uploadId": second}), + ) + .unwrap(); + session.uploads.release(1); + assert!(slot.cancelled.load(Ordering::Acquire)); + assert!(lock(&session.uploads.0).is_empty()); + drop(busy); + drop(slot); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + assert_eq!(fs::read(root.join("second")).unwrap(), b"x"); +} + +#[test] +fn expiry_and_abort_do_not_wait_for_an_active_upload_worker() { + let session = Session::new(true).unwrap(); + let context = Context { + session: Arc::clone(&session), + connection: 1, + closed: Arc::default(), + }; + let expired = Arc::new(Slot { + owner: 1, + expires: Instant::now(), + cancelled: AtomicBool::new(false), + upload: Mutex::new(None), + }); + lock(&session.uploads.0).insert(1, Arc::clone(&expired)); + let busy = lock(&expired.upload); + session.uploads.expire(); + assert!(expired.cancelled.load(Ordering::Acquire)); + drop(busy); + let active = Arc::new(Slot { + owner: 1, + expires: Instant::now() + LIFETIME, + cancelled: AtomicBool::new(false), + upload: Mutex::new(None), + }); + lock(&session.uploads.0).insert(2, Arc::clone(&active)); + let busy = lock(&active.upload); + session + .uploads + .handle(&context, methods::FS_UPLOAD_ABORT, json!({"uploadId": 2})) + .unwrap(); + assert!(active.cancelled.load(Ordering::Acquire)); + drop(busy); + session.terminate_all(); +} + +struct Cleanup(Arc, PathBuf); +impl Drop for Cleanup { + fn drop(&mut self) { + self.0.terminate_all(); + fs::remove_dir_all(&self.1).unwrap(); + } +} diff --git a/rust/lithe-remote-server/tests/run_directories.rs b/rust/lithe-remote-server/tests/run_directories.rs new file mode 100644 index 0000000000..4d4c65de75 --- /dev/null +++ b/rust/lithe-remote-server/tests/run_directories.rs @@ -0,0 +1,246 @@ +//! Runtime snapshot allocation and cleanup through the real protocol. + +mod support; + +use lithe_remote_protocol::{methods, ErrorCode}; +use serde_json::json; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::Path; +use support::{TempDir, STEP_TIMEOUT}; + +#[test] +fn run_directory_release_is_idempotent_and_never_adopts_existing_paths() { + let home = TempDir::new("run-home"); + let (session, _) = support::connect_with_environment(&[("HOME", home.str())]); + let request = |method: &str, id: &str| { + session.request_value(method, json!({"directoryId": id}), STEP_TIMEOUT) + }; + assert!(session + .hello() + .host + .features + .contains(&methods::Feature::RunDirectories)); + let allocated = request(methods::FS_CREATE_RUN_DIRECTORY, "owned").unwrap(); + let path = Path::new(allocated["path"].as_str().unwrap()); + assert_eq!(std::fs::metadata(path).unwrap().mode() & 0o777, 0o700); + std::fs::write(path.join("plugin"), b"approved bytes").unwrap(); + assert!(request(methods::FS_CREATE_RUN_DIRECTORY, "owned").is_err()); + request(methods::FS_RELEASE_RUN_DIRECTORY, "owned").unwrap(); + request(methods::FS_RELEASE_RUN_DIRECTORY, "owned").unwrap(); + assert!(!path.exists()); + std::fs::create_dir(path).unwrap(); + std::fs::write(path.join("user"), b"not owned").unwrap(); + assert!(request(methods::FS_CREATE_RUN_DIRECTORY, "owned").is_err()); + request(methods::FS_RELEASE_RUN_DIRECTORY, "owned").unwrap(); + assert_eq!(std::fs::read(path.join("user")).unwrap(), b"not owned"); + let invalid = request(methods::FS_CREATE_RUN_DIRECTORY, "../escape").unwrap_err(); + assert!( + matches!(invalid, lithe_remote_client::session::ClientError::Remote(error) if error.code == ErrorCode::InvalidRequest) + ); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn another_session_cannot_adopt_or_release_an_active_snapshot() { + let home = TempDir::new("shared-run-home"); + let (owner, _) = support::connect_with_environment(&[("HOME", home.str())]); + let (other, _) = support::connect_with_environment(&[("HOME", home.str())]); + let params = json!({"directoryId": "same-identity"}); + let allocated = owner + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + params.clone(), + STEP_TIMEOUT, + ) + .unwrap(); + let path = Path::new(allocated["path"].as_str().unwrap()); + std::fs::write(path.join("plugin"), b"owned bytes").unwrap(); + assert!(other + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + params.clone(), + STEP_TIMEOUT + ) + .is_err()); + other + .request_value( + methods::FS_RELEASE_RUN_DIRECTORY, + params.clone(), + STEP_TIMEOUT, + ) + .unwrap(); + other.shutdown(STEP_TIMEOUT); + assert_eq!(std::fs::read(path.join("plugin")).unwrap(), b"owned bytes"); + owner + .request_value(methods::FS_RELEASE_RUN_DIRECTORY, params, STEP_TIMEOUT) + .unwrap(); + assert!(!path.exists()); + owner.shutdown(STEP_TIMEOUT); +} + +#[test] +fn failed_release_reports_an_error_and_keeps_ownership_for_retry() { + let home = TempDir::new("run-retry-home"); + let (session, _) = support::connect_with_environment(&[("HOME", home.str())]); + let params = json!({"directoryId": "retry"}); + let allocated = session + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + params.clone(), + STEP_TIMEOUT, + ) + .unwrap(); + let path = Path::new(allocated["path"].as_str().unwrap()); + // Replacing the empty owned directory with a file produces a deterministic + // removal error, even when the suite runs as root on Linux. + std::fs::remove_dir(path).unwrap(); + std::fs::write(path, b"not a directory").unwrap(); + assert!(session + .request_value( + methods::FS_RELEASE_RUN_DIRECTORY, + params.clone(), + STEP_TIMEOUT + ) + .is_err()); + assert_eq!(std::fs::read(path).unwrap(), b"not a directory"); + std::fs::remove_file(path).unwrap(); + std::fs::create_dir(path).unwrap(); + std::fs::write(path.join("plugin"), b"retry cleanup").unwrap(); + session + .request_value(methods::FS_RELEASE_RUN_DIRECTORY, params, STEP_TIMEOUT) + .unwrap(); + assert!(!path.exists()); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn allocation_rejects_shared_or_linked_parents_without_changing_them() { + let home = TempDir::new("run-parent-home"); + let base = home.path().join(".lithe-server"); + std::fs::create_dir(&base).unwrap(); + std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o755)).unwrap(); + let (session, _) = support::connect_with_environment(&[("HOME", home.str())]); + let params = json!({"directoryId": "private"}); + let error = session + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + params.clone(), + STEP_TIMEOUT, + ) + .unwrap_err(); + assert!( + matches!(error, lithe_remote_client::session::ClientError::Remote(error) + if error.code == ErrorCode::PermissionDenied) + ); + assert_eq!(std::fs::metadata(&base).unwrap().mode() & 0o777, 0o755); + assert!(!base.join("plugin-runs").exists()); + std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)).unwrap(); + let target = home.path().join("user-data"); + std::fs::create_dir(&target).unwrap(); + std::os::unix::fs::symlink(&target, base.join("plugin-runs")).unwrap(); + assert!(session + .request_value(methods::FS_CREATE_RUN_DIRECTORY, params, STEP_TIMEOUT) + .is_err()); + assert!(std::fs::symlink_metadata(base.join("plugin-runs")) + .unwrap() + .is_symlink()); + assert_eq!(std::fs::read_dir(target).unwrap().count(), 0); + session.shutdown(STEP_TIMEOUT); +} + +#[test] +fn lost_client_snapshot_is_released_when_the_stdio_session_ends() { + let home = TempDir::new("run-disconnect-home"); + let (session, _) = support::connect_with_environment(&[("HOME", home.str())]); + let allocated = session + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + json!({"directoryId": "disconnected"}), + STEP_TIMEOUT, + ) + .unwrap(); + let path = Path::new(allocated["path"].as_str().unwrap()); + std::fs::write(path.join("plugin"), b"approved bytes").unwrap(); + drop(session); + // Server exit is external to this process; observe the owned path with a + // monotonic deadline, never wait on a fixed sleep or shared user directory. + let deadline = std::time::Instant::now() + STEP_TIMEOUT; + while path.exists() { + assert!( + std::time::Instant::now() < deadline, + "stdio disconnect leaked its snapshot" + ); + std::thread::yield_now(); + } +} + +#[test] +fn persistent_disconnect_retains_snapshot_until_explicit_shutdown() { + let home = TempDir::new("persistent-run-home"); + let daemon = support::Daemon::new("persistent-run"); + let mut command = vec!["env".into(), format!("HOME={}", home.str())]; + command.extend(daemon.command()); + let (session, _) = support::connect_command(&command); + let allocated = session + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + json!({"directoryId": "persistent"}), + STEP_TIMEOUT, + ) + .unwrap(); + let path = Path::new(allocated["path"].as_str().unwrap()); + std::fs::write(path.join("plugin"), b"approved bytes").unwrap(); + drop(session); + let (replacement, _) = support::connect_command(&command); + assert_eq!( + std::fs::read(path.join("plugin")).unwrap(), + b"approved bytes" + ); + // The consuming process is still using this snapshot when the session + // ends. Teardown must stop it before removing its working directory. + let process = replacement + .spawn(lithe_remote_client::SpawnSpec { + argv: vec!["/bin/cat".into()], + cwd: Some(path.to_string_lossy().into_owned()), + pipe_stdin: true, + ..Default::default() + }) + .unwrap(); + let pid = process.pid; + drop(process); // Dropping the subscription leaves the consumer running. + replacement.shutdown(STEP_TIMEOUT); + assert!(daemon.wait_until_stopped()); + assert!( + support::wait_until_gone(pid), + "snapshot consumer survived shutdown" + ); + assert!(!path.exists()); +} + +#[test] +fn abandoned_desktop_snapshot_is_reclaimed_after_persistent_session_expiry() { + let home = TempDir::new("expired-run-home"); + let daemon = support::Daemon::new("expired-run"); + let mut command = vec!["env".into(), format!("HOME={}", home.str())]; + command.extend(daemon.command()); + let mut options = support::options(); + options.idle_timeout = Some(std::time::Duration::from_secs(1)); + let (session, _) = lithe_remote_client::RemoteSession::connect( + &lithe_remote_client::TransportChain::local(), + &command, + &options, + ) + .unwrap(); + let allocated = session + .request_value( + methods::FS_CREATE_RUN_DIRECTORY, + json!({"directoryId": "expired"}), + STEP_TIMEOUT, + ) + .unwrap(); + let path = Path::new(allocated["path"].as_str().unwrap()); + std::fs::write(path.join("plugin"), b"approved bytes").unwrap(); + drop(session); + assert!(daemon.wait_until_stopped()); + assert!(!path.exists()); +} diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index e5a1f28fc8..077b1b6baf 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -404,6 +404,8 @@ async function main() { // have execution/user ownership, never a build stamp or copy stage. // Build/provider state, approved plugin-run snapshots, upload staging and // native marketplace extraction also stay on that rejection route. + // Session-owned runDirectory identities survive persistent reconnects and + // expire with the session; they never authorize a resource-copy validator. // Includes plugin-disk-install-state: fileStorage temporary snapshots, install staging // and active/previous approvals are runtime-owned. Reject before resolving worktrees // or calling validatorArguments; a content digest is neither a build stamp nor consent. diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index d694636d87..8ff9882620 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -79,6 +79,8 @@ try { const state = registry.excludedResources.find(item => item.id === "remote-connection-state"); assert.ok(state.locations.some(location => location.includes("session-and-binary-hash"))); const snapshots = registry.excludedResources.find(item => item.id === "remote-workspace-session-state"); + assert.match(snapshots.reason, /fs\.createRunDirectory/); + assert.match(snapshots.reason, /Forced server termination recovery remains unsupported/); assert.ok(snapshots.locations.some(location => location.includes("lithe-git-"))); assert.ok(snapshots.locations.some(location => location.includes(""))); assert.ok(snapshots.locations.some(location => location.includes(".lithe-copy-"))); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index a423ed47f0..d7b077dc1d 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -401,7 +401,7 @@ "//remote-git-executable-and-search-index" ], "identity": "Mutable per-operation Git/build scratch, private plugin snapshots, connection-owned upload staging, provider workspace state and per-connection runtime, shell, executable and search snapshots. They have no reusable version/platform/architecture/toolchain build identity.", - "reason": "The existing workspace engines own these records while generic host filesystem/process/PTY adapters perform I/O. Git/build scratch lives on its host and is never mirrored into the local placeholder. Upload staging expires or is removed on abort/disconnect; completed plugin runs are removed after process destruction, but crash/disconnect recovery of published runs remains TODO. Provider state persists on the host. Native marketplace extraction is deleted after transfer or failure. Package trust/hash/architecture checks authorize one execution and do not establish reusable build identity. Host discovery is memory-only and invalidated on explicit refresh or connection replacement. All reuse stages reject these entries before validation or copying. Runtime writes do not reach installed bundles, code signatures or Sparkle delta baselines." + "reason": "The existing workspace engines own these records while generic host filesystem/process/PTY adapters perform I/O. Git/build scratch lives on its host and is never mirrored into the local placeholder. Upload staging expires or is removed on abort/disconnect; complete plugin runs are allocated by fs.createRunDirectory and released by session-owned identity after process destruction. Nonpersistent disconnect, shutdown and idle expiry remove published snapshots after worker drain; persistent disconnect retains them. Forced server termination recovery remains unsupported, and old unmanaged snapshots are never adopted or age-scanned. Provider state persists on the host. Native marketplace extraction is deleted after transfer or failure. Package trust/hash/architecture checks authorize one execution and do not establish reusable build identity. Host discovery is memory-only and invalidated on explicit refresh or connection replacement. All reuse stages reject these entries before validation or copying. Runtime writes do not reach installed bundles, code signatures or Sparkle delta baselines." }, { "id": "shared-i18n", diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/shared/contracts/remote-workspace-protocol-v1.md index 1c24f71380..01d2639bcb 100644 --- a/shared/contracts/remote-workspace-protocol-v1.md +++ b/shared/contracts/remote-workspace-protocol-v1.md @@ -105,6 +105,8 @@ subscribe before creating the resource. | `fs.uploadChunk` | `uploadId`, `offset`, `data` | next `offset` | | `fs.uploadCommit` | `uploadId` | verified `size`, `sha256` | | `fs.uploadAbort` | `uploadId` | `null` | +| `fs.createRunDirectory` | `directoryId` | private session-owned `path` | +| `fs.releaseRunDirectory` | `directoryId` | `null`; idempotent release of this session's allocation | | `walk.start` | `walkId`, `root`, `excludeNames`, `maxEntries?` | `null`; entries follow as events | | `walk.cancel` | `walkId` | `null` | | `watch.start` | `watchId`, `root`, `excludeNames` | `watchedDirectories` (per-directory backends) | @@ -160,6 +162,34 @@ expiry remove staging files. Clients serialize chunks and commit only after the last acknowledgement. Interrupted clients may not know whether a completed commit published; owners must remove their allocated run directory separately. +Upload registry locks do not cover file creation, writes, `fsync`, publication +or deletion. Operations on one upload use a separate nonblocking lock (`BUSY` +for concurrent operations on that upload). Abort, expiry and disconnect mark +an in-flight upload cancelled without waiting for that lock; its worker retains +staging cleanup responsibility. Independent uploads remain usable. + +The optional `runDirectories` feature owns complete runtime input snapshots, +not just unpublished upload files. Clients choose a unique `directoryId` before +allocation (1–64 ASCII letters, digits, `_` or `-`), so a cancelled or lost reply +can still be released by identity. The host creates a fresh `0700` directory +below its private `~/.lithe-server/plugin-runs/`; it never adopts an existing +directory or accepts an arbitrary cleanup path. At most 256 allocations belong +to a session. A later release is ordered after a pending allocation even when +its worker has not started. Release is idempotent and affects only that +session's allocation. A removal failure is returned to the waiting release +request, and ownership remains registered so release can be retried. Existing +parent directories must be real, owned by the host user and inaccessible to +group/other users; allocation never changes their permissions. +Clients stop the consuming process before release. + +Nonpersistent disconnect and session shutdown remove these directories after +process teardown and worker drain. Persistent disconnect retains them for +reconnection; an abandoned desktop's directories are removed at idle expiry. +This is not crash recovery of a forcibly killed server: such directories remain +until a future process-liveness-aware recovery mechanism or manual cleanup. +No age-based scan deletes other sessions' snapshots. Installed resources and +persistent provider state are never cleanup targets. + `process.exit` and `terminal.exit` report the leader. The server keeps owning its session until no member is left, so members that outlive the leader can still be killed and are cleaned up when the session ends; `server.state` diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index dbf6ead087..db9d3b919a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3579,6 +3579,18 @@ Successful upload ownership transfers to the caller, which removes its run snapshot after the process has ended. The native source and installation remain read-only. See the staged upload limits in `remote-workspace-protocol-v1.md`. +Plugin deployment requires both `upload` and `runDirectories` host features. +Through `remote.hostRequest`, `fs.createRunDirectory` accepts a caller-generated +`directoryId` and returns a fresh host `path`; `fs.releaseRunDirectory` accepts +the same identity and is idempotent. Allocation may finish after Core cancellation, +but the host owns it and orders a subsequent release after pending creation. +The platform retains the original connection and the identity before allocation, +releases after process destruction, and never falls back to unchecked `fs.remove`. +Nonpersistent disconnect or session termination also collects full snapshots; +persistent disconnect retains them until explicit release, shutdown or idle expiry. +Forced server termination recovery remains unsupported, rather than risking +deletion of inputs used by surviving processes. + ### Git patch discovery and rebase amendment preconditions `git.patchExport` accepts optional `metadataOnly` (default false). In metadata diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index 3d09149d0f..d0847325ea 100644 --- a/shared/platform-feature-matrix/features/remote-development-workspace.json +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -34,7 +34,10 @@ "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift", "macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift", - "rust/lithe-remote-server/src/connection/tests.rs" + "rust/lithe-remote-server/src/connection/tests.rs", + "rust/lithe-remote-server/src/run_directory.rs", + "rust/lithe-remote-server/tests/run_directories.rs", + "rust/lithe-remote-server/src/upload/tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -47,5 +50,5 @@ "verificationStatus": "not-applicable" }, "owner": "Workspace", - "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、完整插件快照的断线/崩溃回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。" + "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、远端服务强制终止后的快照安全回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。" } diff --git a/shared/platform-feature-matrix/features/remote-workspace.json b/shared/platform-feature-matrix/features/remote-workspace.json index a06143ffb8..4850c39090 100644 --- a/shared/platform-feature-matrix/features/remote-workspace.json +++ b/shared/platform-feature-matrix/features/remote-workspace.json @@ -16,7 +16,10 @@ "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift", "macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift", - "rust/lithe-remote-server/src/connection/tests.rs" + "rust/lithe-remote-server/src/connection/tests.rs", + "rust/lithe-remote-server/src/run_directory.rs", + "rust/lithe-remote-server/tests/run_directories.rs", + "rust/lithe-remote-server/src/upload/tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -30,5 +33,5 @@ "verificationStatus": "pending" }, "owner": "Remote", - "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 从欢迎页远程开发入口连接 Linux SSH 主机,验证文件树、编辑保存冲突、断线通知与重连。macOS 路由与断线 fixture 已有自动测试,真实 SSH 流程仍待验收;完整远程开发进度见 remote-development-workspace。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 构建与插件接入进度同步至 remote-development-workspace;本行保持 partial/pending,不能以本机 fixture 替代真实 SSH 验收。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。" + "verification": "Windows 验证连接、密码提示、远程路径、断线和重连;macOS 从欢迎页远程开发入口连接 Linux SSH 主机,验证文件树、编辑保存冲突、断线通知与重连。macOS 路由与断线 fixture 已有自动测试,真实 SSH 流程仍待验收;完整远程开发进度见 remote-development-workspace。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 构建与插件接入进度同步至 remote-development-workspace;本行保持 partial/pending,不能以本机 fixture 替代真实 SSH 验收。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。" } From 091356cc0cdbcbbca5727e0e78e7d149c33705b5 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 29 Oct 2026 08:53:24 +0800 Subject: [PATCH 4/5] fix(remote): preserve repository identity and consolidate remote crate Keep remote Git repository roots and external metadata bound to their original host, including parent repositories and symlink roots. Consolidate client, protocol and server into lithe-remote with isolated client/server features, preserving the standalone server binary. Update consumers, packaging, CI selection, resource validation, contracts and regression coverage. --- .../2026-10-24-remote-workspace-routing.md | 16 ++- .../2026-10-24-remote-ssh-connections.md | 10 +- ...10-24-remote-development-workspace-host.md | 22 ++-- .../references/test-reporting.md | 5 + .../scripts/run-rust-tests-with-timing.mjs | 11 ++ .../scripts/test-verify-test-stability.mjs | 9 ++ .github/workflows/ci-remote.yml | 4 +- docs/ci-builds.md | 3 +- .../Remote/RemoteDirectoryObservation.swift | 10 +- .../Remote/RemoteWorkspaceRouting.swift | 21 ++++ .../LitheTests/RemoteConnectionsTests.swift | 46 ++++++++ .../RemoteDirectoryObservationTests.swift | 38 +++++- rust/Cargo.lock | 26 +---- rust/Cargo.toml | 2 +- rust/lithe-core/Cargo.toml | 5 +- rust/lithe-core/src/build/invocation.rs | 4 +- rust/lithe-core/src/git/mod.rs | 3 +- rust/lithe-core/src/git/remote_history.rs | 6 +- rust/lithe-core/src/git/remote_process.rs | 6 +- .../src/lsp/interface/remote_process.rs | 8 +- rust/lithe-core/src/remote/mod.rs | 8 +- rust/lithe-core/src/remote/sessions.rs | 20 ++-- .../src/remote/sessions_integration_tests.rs | 110 +++++++++++++++++- rust/lithe-core/src/remote/upload.rs | 6 +- rust/lithe-core/src/remote/workspace.rs | 29 ++++- rust/lithe-core/src/remote/workspace_host.rs | 12 +- .../src/remote/workspace_host/tests.rs | 6 +- rust/lithe-core/src/remote/workspace_io.rs | 4 +- rust/lithe-remote-client/Cargo.toml | 13 --- rust/lithe-remote-protocol/Cargo.toml | 12 -- rust/lithe-remote-server/Cargo.toml | 20 ---- rust/lithe-remote-server/src/ports.rs | 16 --- rust/lithe-remote/Cargo.toml | 56 +++++++++ .../src/bin/lithe-remote-server.rs} | 12 +- .../src => lithe-remote/src/client}/api.rs | 6 +- .../src/client}/bootstrap.rs | 8 +- .../src/client}/command.rs | 0 .../src/client}/forward.rs | 10 +- .../src => lithe-remote/src/client}/launch.rs | 14 ++- .../lib.rs => lithe-remote/src/client/mod.rs} | 0 .../src/client}/session.rs | 8 +- .../src/client}/transport.rs | 5 +- rust/lithe-remote/src/lib.rs | 14 +++ .../src/protocol}/frame.rs | 0 .../src/protocol}/message.rs | 0 .../src/protocol}/methods.rs | 2 +- .../src/protocol/mod.rs} | 0 .../src/server}/connection.rs | 18 +-- .../src/server}/connection/tests.rs | 3 +- .../src => lithe-remote/src/server}/daemon.rs | 14 +-- .../src/server}/environment.rs | 16 +-- .../src => lithe-remote/src/server}/fs.rs | 6 +- .../src => lithe-remote/src/server}/link.rs | 12 +- .../lib.rs => lithe-remote/src/server/mod.rs} | 8 +- .../src/server}/platform/linux.rs | 2 +- .../src/server}/platform/linux_watch.rs | 4 +- .../src/server}/platform/macos.rs | 2 +- .../src/server}/platform/macos_watch.rs | 6 +- .../src/server}/platform/mod.rs | 4 +- .../src/server}/platform/unix.rs | 0 rust/lithe-remote/src/server/ports.rs | 17 +++ .../src/server}/process.rs | 20 ++-- .../src/server}/resources.rs | 30 ++--- .../src/server}/run_directory.rs | 12 +- .../src/server}/session.rs | 39 ++++--- .../src/server}/spawner.rs | 2 +- .../src/server}/terminal.rs | 37 +++--- .../src => lithe-remote/src/server}/tunnel.rs | 16 +-- .../src => lithe-remote/src/server}/upload.rs | 10 +- .../src/server}/upload/tests.rs | 4 +- .../src => lithe-remote/src/server}/walk.rs | 18 +-- .../src => lithe-remote/src/server}/watch.rs | 22 ++-- .../tests/persistence.rs | 10 +- .../tests/processes.rs | 14 +-- .../tests/run_directories.rs | 12 +- .../tests/server.rs | 20 ++-- .../tests/support/mod.rs | 6 +- .../tests/terminals.rs | 6 +- .../tests/tunnels.rs | 14 +-- .../tests/watch_and_walk.rs | 4 +- scripts/build-remote-servers.sh | 5 +- scripts/classify-ci-changes.sh | 12 +- scripts/reuse-worktree-resources.mjs | 3 +- scripts/test-classify-ci-changes.sh | 13 ++- scripts/test-reuse-worktree-resources.mjs | 6 + scripts/verify-remote-workspace.sh | 47 +++++--- scripts/worktree-resources.json | 2 +- .../contracts/remote-workspace-protocol-v1.md | 2 +- shared/contracts/rust-core-api.md | 24 ++++ .../remote-development-workspace.json | 16 ++- .../features/remote-workspace.json | 8 +- windows/tauri/src-tauri/Cargo.lock | 89 +++++--------- 92 files changed, 781 insertions(+), 500 deletions(-) delete mode 100644 rust/lithe-remote-client/Cargo.toml delete mode 100644 rust/lithe-remote-protocol/Cargo.toml delete mode 100644 rust/lithe-remote-server/Cargo.toml delete mode 100644 rust/lithe-remote-server/src/ports.rs create mode 100644 rust/lithe-remote/Cargo.toml rename rust/{lithe-remote-server/src/main.rs => lithe-remote/src/bin/lithe-remote-server.rs} (86%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/api.rs (99%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/bootstrap.rs (99%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/command.rs (100%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/forward.rs (94%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/launch.rs (93%) rename rust/{lithe-remote-client/src/lib.rs => lithe-remote/src/client/mod.rs} (100%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/session.rs (99%) rename rust/{lithe-remote-client/src => lithe-remote/src/client}/transport.rs (99%) create mode 100644 rust/lithe-remote/src/lib.rs rename rust/{lithe-remote-protocol/src => lithe-remote/src/protocol}/frame.rs (100%) rename rust/{lithe-remote-protocol/src => lithe-remote/src/protocol}/message.rs (100%) rename rust/{lithe-remote-protocol/src => lithe-remote/src/protocol}/methods.rs (99%) rename rust/{lithe-remote-protocol/src/lib.rs => lithe-remote/src/protocol/mod.rs} (100%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/connection.rs (96%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/connection/tests.rs (99%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/daemon.rs (97%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/environment.rs (94%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/fs.rs (99%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/link.rs (95%) rename rust/{lithe-remote-server/src/lib.rs => lithe-remote/src/server/mod.rs} (92%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/platform/linux.rs (98%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/platform/linux_watch.rs (99%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/platform/macos.rs (98%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/platform/macos_watch.rs (98%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/platform/mod.rs (95%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/platform/unix.rs (100%) create mode 100644 rust/lithe-remote/src/server/ports.rs rename rust/{lithe-remote-server/src => lithe-remote/src/server}/process.rs (97%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/resources.rs (89%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/run_directory.rs (95%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/session.rs (84%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/spawner.rs (97%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/terminal.rs (92%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/tunnel.rs (95%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/upload.rs (97%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/upload/tests.rs (97%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/walk.rs (94%) rename rust/{lithe-remote-server/src => lithe-remote/src/server}/watch.rs (95%) rename rust/{lithe-remote-server => lithe-remote}/tests/persistence.rs (97%) rename rust/{lithe-remote-server => lithe-remote}/tests/processes.rs (95%) rename rust/{lithe-remote-server => lithe-remote}/tests/run_directories.rs (95%) rename rust/{lithe-remote-server => lithe-remote}/tests/server.rs (98%) rename rust/{lithe-remote-server => lithe-remote}/tests/support/mod.rs (97%) rename rust/{lithe-remote-server => lithe-remote}/tests/terminals.rs (96%) rename rust/{lithe-remote-server => lithe-remote}/tests/tunnels.rs (95%) rename rust/{lithe-remote-server => lithe-remote}/tests/watch_and_walk.rs (98%) diff --git a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md index 7e4ff564a3..e5bdbd1417 100644 --- a/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md +++ b/.agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md @@ -12,14 +12,16 @@ ## 决策 +- **远程实现只保留一个 crate**:`rust/lithe-remote/` 内部分为 `client`、`protocol`、`server` 三个模块,共用一个版本和依赖入口。协议类型始终可用,Cargo feature(构建时选择哪些代码和依赖)默认只启用 `client`;Core 的正常依赖明确只选客户端,Linux/macOS 的真实服务测试额外启用 `server`。打包必须用 `--no-default-features --features server --bin lithe-remote-server`,不能让工作区其他包的 feature 合并把客户端带入远端发布物。可执行文件仍叫 `lithe-remote-server`,安装路径、握手和协议版本不变;协议单独构建不需要 SHA-256 或主机适配依赖,客户端不引入 `trash` 或服务端平台代码。 - **占位根目录**:`MacRemoteWorkspaceRoots` 在 `Application Support/Lithe/RemoteWorkspaces/<连接 id>/<远程路径哈希>/<文件夹名>` 创建一个空目录,用它作为工作区根。目录名是远程文件夹名,所以项目名显示正确。它不在 app bundle 里,不影响签名和 Sparkle 增量包;里面没有用户数据,跨启动保留,让按根目录保存的项目状态得以延续。 - **注册表**:`RemoteWorkspaceRegistry` 记录每个占位根对应的连接、远程路径和 Core 会话 id。打开远程项目时登记,同一个根再次打开时,旧会话会被替换并断开。 - **路由**:组合根(`MacServiceContainer`,负责组装依赖的地方)把本地实现包进 `RemoteRoutingWorkspaceOperations` 和 `RemoteRoutingFileOperations`。路由层对端口里的**每个**方法都显式转发:如果只覆盖一部分,没覆盖的方法会悄悄走协议扩展里的默认实现,本地文件的行为就变了。 - **文档版本**:和本地一样,文档标识是读到的原始字节的 SHA-256。远端用大小和修改时间做冲突检测,所以路由层为每个读到的标识记住远端版本(最多 512 个),保存时把它作为 `expected` 传给 `remote.writeFile`;不认识的标识会先读当前内容比对哈希。文件变了就返回冲突,不覆盖。 - **文件树变更**:新文件只创建,不覆盖已存在文件;重命名、目录复制和删除都使用同一会话的主机文件协议,跨本机与远端或不同会话的移动和复制明确失败。复制先在目标父目录的私有暂存目录完成,再原子发布且拒绝覆盖,避免并发创建目标时丢失内容。界面的“移到废纸篓”复用 `trash` 上游库,在 Linux 使用 Freedesktop Trash、在远程 Mac 使用系统 Trash;不可用时失败,不改成永久删除。需要按内容标识保护的 `trashDocument` 仍待单独适配,不能假装普通 Trash 操作具有原子身份保护。 -- **外部修改监听**:`RemoteDirectoryObservation` 在工作线程订阅主机原生 watcher,转换工作区内路径后交给已有文件树、Git、搜索索引和文档刷新流程。Git common directory 位于工作区外时保留主机路径并额外监听。订阅先于创建,启动和溢出要求重新扫描,根目录移除结束订阅;取消先屏蔽旧 generation,再通过 Core 的资源所有者停止主机 watcher。打开的文档共用其工作区根的订阅,列表缓存随文档变更清空。 +- **外部修改监听**:`RemoteDirectoryObservation` 在工作线程订阅主机原生 watcher,转换路径后交给已有文件树、Git、搜索索引和文档刷新流程。Git common directory 位于工作区外时使用同一绑定的外部路径命名空间并额外监听,订阅时还原主机路径,事件回来后投影回同一个本地身份。订阅先于创建,启动和溢出要求重新扫描,根目录移除结束订阅;取消先屏蔽旧 generation,再通过 Core 的资源所有者停止主机 watcher。打开的文档共用其工作区根的订阅,列表缓存随文档变更清空。 - **最近项目**:占位根目录不进“最近项目”,因为它离开连接就只是个空目录;重新打开要走“远程开发”窗口。 - **Core 请求的主机身份**:桥接层只读取请求结构里明确的工作区根字段,附上注册表中的会话与两侧根目录;同一次 Git 操作涉及不同主机或本机与远端混合时拒绝执行。Core 在请求范围内持有这份身份,长期历史游标继续持有原连接,不依赖“当前选中主机”的全局变量。远端出错时返回失败,不访问本机占位目录或本机 Git。 +- **工作区外的主机路径仍带身份**:打开远端 `/srv/repo/src` 后,Git 发现的父仓库 `/srv/repo` 不能作为普通本地绝对路径返回,否则下一次状态或暂存会误操作本机。Core 把这类路径放进占位根的虚拟同级目录 `<根目录名>.lithe-remote-host/<主机绝对路径去掉开头斜杠>`;注册表识别为原绑定,后续请求仍传原来的会话和两侧根,Core 再还原真实路径。符号链接解析到工作区外也走同一规则。同级而非子目录避免和用户文件名冲突,不使用 `..` 越出根,也不凭原始 `/srv/repo` 猜测主机。这个命名空间不创建文件、不增加缓存或资源目录;只有原空占位根跨启动保留,绑定注销后两种身份一起移除。 - **Git 与搜索继续复用现有引擎**:Git 参数、状态解析、提交、历史和搜索索引仍由原 Core 模块负责。文件与进程适配器只把请求送到项目所在主机;Git 可执行文件与版本按连接缓存,设置来源由同一主机 Git 返回。创建或管理 worktree、克隆时,仅映射类型化请求中明确的目标目录字段;原始 Git 参数、仓库地址和用户文本保持原样。搜索缓存增加连接身份,避免同一个占位 URL 曾经生成的本机空索引污染远端结果。远端服务不引入 Core、JDK、LSP 或 Agent Host;Linux musl 二进制各保持 1.5 MiB 上限与 panic unwind(线程出错时保留其他会话)。 - **远端文件读写限制**:Core 一次装入内存的文件最多 32 MiB,分块读取还会核对版本、偏移与结束标记;流式快照哈希可读取更大的文件。原子写入沿用协议的 8 MiB 上限,超限在编码和发送前失败,不拆成会暴露半份内容的覆盖操作。文件或目录是否存在的查询必须保留权限、断线等失败,不能把它们当成“不存在”。临时目录清理失败记录错误类别,避免静默残留或把用户路径写入日志。 - **终端确认与清理**:交互终端默认使用远端账户的 Shell,显式菜单选择保留独立 argv;调试进程按工作区身份选择主机,即使调试器给出的工作目录已经是远端绝对路径。启动在收到远端确认后才发布就绪与 PID,等待中的启动也算模块活动资源。重启先等待上次传输清理,输入按顺序发送,待发送尺寸只保留最新值;写入与缩放复用 Core 的操作取消,退出释放远程渲染标记,不把远端 PID 交给本机进程监控。 @@ -45,6 +47,10 @@ ## 考虑过的备选方案 +### 保留三个 crate,或合并后默认编译全部模块 + +三者总是随同一份协议一起维护,没有独立发布需求,保留三个包只会增加依赖入口和路径维护。全部默认编译又会把只适用于 Linux/macOS 的服务端带进 Windows 客户端。采用单包加可选 feature,减少顶层模块而保留平台和依赖边界;代价是验证必须覆盖纯协议、纯客户端、纯服务端和两端一起启用四种组合,并继续检查远端二进制大小。 + ### 为远程文件使用自定义 URL scheme 语义最清楚,但工作台里几乎所有地方都假定是文件 URL,第一步就要改全部功能。 @@ -53,6 +59,10 @@ 现有功能全都能直接用,但大项目同步慢、两边会不一致,构建和运行也会发生在本地而不是项目所在的机器上,和整体远程开发设计(构建、LSP 都在远端运行)相冲突。 +### 丢弃工作区外的仓库,或把父路径拼成 `../` + +丢弃父仓库可以避免错误路由,但打开仓库子目录时 Git 会完全不可用,符号链接根也有同样问题。`../` 会被 URL 标准化消掉,不再位于已登记的占位根下。采用可逆的同级虚拟命名空间,让完整的“发现仓库 → 查询状态 → 暂存”保留主机身份,而不扩大原占位根对应的主机目录。 + ### 客户端取消时只重试关闭,或等待启动确认后才允许取消 只重试关闭仍可能在资源登记前用尽客户端的两秒清理期限;无限等待启动确认又会拖住关闭窗口和模块停用。也不能把慢启动改成连接读取线程上的同步工作,那会阻塞其他会话。采用服务端先记住取消、创建者随后清理的方式,让客户端保持有界等待,同时不丢失最终清理责任。 @@ -61,6 +71,7 @@ - 远程工作区第一版:文件树按需列目录、打开和保存 UTF-8 文件(含 BOM)、保存时检测冲突。 - 新增适配覆盖:远端 PTY、按主机探测环境、Git 文件与进程 I/O、搜索索引与 Shift+Shift 的文件及内容查询。真实服务 fixture 已覆盖状态、暂存、分页历史、设置来源、worktree 创建和管理、克隆、保留无关暂存的选择文件提交,以及未保存缓冲区的行历史;不能据此把完整真实 SSH 产品流程标为已验收。 +- 父仓库与符号链接根的服务 fixture 经过发现、状态、暂存三次独立请求,并在返回的虚拟 URL 放置本机 Git 哨兵,核对本机索引和文件未变;Swift 路由 fixture 检查原绑定恢复、同路径不同主机隔离及混合根拒绝。外部 Git 元数据 watcher 的事件也采用相同投影;这些仍不替代真实 SSH 验收。 - 文件树新建、复制、重命名、移到主机废纸篓和普通删除已接入;外部修改监听覆盖文件树与文档,真实 SSH 的原生 watcher 仍需产品验收。 - 构建 I/O、工具与状态目录适配,以及通用插件部署和 stdio 提供者接入已有实现与 fixture;真实 SSH 的完整构建、插件启动与取消链路仍待验收。 - 仍待完成:远程 Java 原生集成、SVN 的兼容目标包与完整 VCS 流程、Agent 执行位置选择、远端服务强制终止后的快照回收,以及 stdio 断线、顺序、背压与取消的真实主机覆盖。Java 和 Agent 不在本轮通用能力续作范围;不能把桌面客户端崩溃后的会话到期清理当成远端服务崩溃回收。不关闭 #1507。 @@ -84,6 +95,9 @@ node scripts/test-reuse-worktree-resources.mjs ## 适用范围 +- `rust/lithe-remote/` +- `scripts/build-remote-servers.sh` +- `scripts/verify-remote-workspace.sh` - `macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift` - `macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteWorkspaceRoots.swift` - `macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift` diff --git a/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md b/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md index 17e368ab4c..1a4f193dbb 100644 --- a/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md +++ b/.agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md @@ -30,7 +30,7 @@ ### 单连接启动 -`lithe_remote_client::launch` 在远端跑一段单行 POSIX `sh` 脚本: +`lithe_remote::client::launch` 在远端跑一段单行 POSIX `sh` 脚本: 1. 先输出主机详细信息和平台标记块; 2. 客户端按平台选出随 App 附带的服务,把“版本 SHA-256 大小 目录名”作为一行写回; @@ -41,7 +41,7 @@ ### 随 App 附带的远端服务 -`scripts/build-remote-servers.sh` 用 Rust 自带的 `rust-lld` 构建两个 Linux musl 静态版,输出到 `rust/target/remote-servers/`,同时写一个 `manifest.json` 记录服务版本。打包时复制到 `Contents/Resources/RemoteServers/<目标>/lithe-remote-server`。 +`scripts/build-remote-servers.sh` 用 Rust 自带的 `rust-lld` 从 `lithe-remote` crate 只启用 `server` feature,构建两个 Linux musl 静态版,输出到 `rust/target/remote-servers/`,同时写一个 `manifest.json` 记录服务版本。打包时复制到 `Contents/Resources/RemoteServers/<目标>/lithe-remote-server`。源码合并不改变这个可执行文件名或安装身份,构建组合见远程工作区路由 Note。 - 这是只读的固定输入:运行时只读取它并上传给主机,不往这个目录写任何东西,所以不影响代码签名和 Sparkle 增量包。 - Linux ELF 文件对 `codesign` 来说只是资源。macOS 版服务是 Mach-O,需要 Developer ID 签名和公证,所以暂时不附带;远程 Mac 会显示“暂不支持这个平台”。 @@ -74,8 +74,8 @@ ## 验证 ```bash -cargo test --manifest-path rust/Cargo.toml -p lithe-remote-client -cargo test --manifest-path rust/Cargo.toml -p lithe-remote-server --test server +cargo test --manifest-path rust/Cargo.toml -p lithe-remote --lib +cargo test --manifest-path rust/Cargo.toml -p lithe-remote --all-features --test server cargo test --manifest-path rust/Cargo.toml -p lithe-core remote:: ./scripts/verify-runtime-bundle-immutability.sh ``` @@ -89,5 +89,5 @@ cargo test --manifest-path rust/Cargo.toml -p lithe-core remote:: - `macos/Sources/Lithe/Services/Remote/RemoteConnectionStore.swift` - `macos/Sources/Lithe/Platform/MacOS/Remote/` - `rust/lithe-core/src/remote/` -- `rust/lithe-remote-client/src/launch.rs` +- `rust/lithe-remote/src/client/launch.rs` - `scripts/build-remote-servers.sh` diff --git a/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md b/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md index 1c5cce1a22..49b554ba9d 100644 --- a/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md +++ b/.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md @@ -26,20 +26,20 @@ Lithe 要支持“完整的远程开发”:项目放在 SSH 主机、Docker 本地 Lithe(界面、设置、钥匙串、本地历史) │ 工作区后端接口(文件 / 进程 / 终端 / 监听 / 搜索 / 端口 / 插件后端) ├── 本地实现(现在的代码,迁移到接口之后) - └── 远程实现 ── lithe-remote-protocol ── 传输链 ── lithe-remote-server + └── 远程实现 ── lithe_remote::protocol ── 传输链 ── lithe-remote-server ├─ 本机子进程(测试用) ├─ ssh 主机 ├─ docker/podman exec 容器 └─ ssh 主机 → 主机上的容器 ``` -三个 Rust crate,职责分开: +一个 `lithe-remote` Rust crate,三个内部模块职责分开;构建 feature 与发布入口以已实现的远程工作区路由 Note 为准: -| crate | 负责什么 | 不负责什么 | +| 模块 | 负责什么 | 不负责什么 | | --- | --- | --- | -| `rust/lithe-remote-protocol` | 帧格式、消息信封、方法名、参数类型、错误码 | 不做任何 I/O 决策 | -| `rust/lithe-remote-server` | 远端的全部能力和 Linux/macOS 适配器 | 不知道自己是被 SSH 还是 Docker 启动的 | -| `rust/lithe-remote-client` | 传输链拼命令、远端安装校验、会话收发 | 不实现文件或进程语义 | +| `rust/lithe-remote/src/protocol` | 帧格式、消息信封、方法名、参数类型、错误码 | 不做任何 I/O 决策 | +| `rust/lithe-remote/src/server` | 远端的全部能力和 Linux/macOS 适配器 | 不知道自己是被 SSH 还是 Docker 启动的 | +| `rust/lithe-remote/src/client` | 传输链拼命令、远端安装校验、会话收发 | 不实现文件或进程语义 | ### 传输链只做“执行一条命令” @@ -114,7 +114,7 @@ Linux 静态版用 Rust 自带的 `rust-lld` 链接,在 macOS 和 Linux 上都 ### 平台适配器(Linux / macOS) -所有平台差异都放在 `rust/lithe-remote-server/src/platform/` 里,并通过能力描述告诉客户端: +所有平台差异都放在 `rust/lithe-remote/src/server/platform/` 里,并通过能力描述告诉客户端: | 差异 | Linux | macOS | 客户端看到的能力 | | --- | --- | --- | --- | @@ -324,7 +324,7 @@ M2 接入“打开远程文件夹”时,SSH 连接的配置界面参考用户 ## 验收标准 -- `cargo test -p lithe-remote-protocol -p lithe-remote-client -p lithe-remote-server` 在 macOS 和 Linux 上通过。端到端测试经本机传输启动真实的服务二进制,按功能分成几个测试文件: +- `cargo test -p lithe-remote --all-features` 在 macOS 和 Linux 上通过。端到端测试经本机传输启动真实的服务二进制,按功能分成几个测试文件: - 代码审查发现的问题都配了回归测试,并通过“临时撤掉修复、确认测试失败”验证过:复制到自身、半关闭请求的响应、断线后不丢不重的续传、空会话及时清理。常驻进程关闭期间重连的那个测试,在修复之前就失败过。 - `tests/server.rs`:握手和能力描述、未握手前的请求和协议版本不匹配、原子写入和冲突检测、并发创建同一文件只有一个成功、断开时正在进行的写入完成且不留临时文件、对端停止读取时请求按时超时、分段读取大文件、穿过符号链接写入、目录操作的错误码、复制时保留链接和权限并拒绝复制到自身内部、复制文件到它自己(含链接和硬链接)被拒绝且文件完好、复制时跳过特殊文件、登录横幅被忽略、服务缺失时返回诊断信息、上传一次之后复用已校验的二进制。 - `tests/processes.rs`:输出先于退出事件、标准输入转发、整个会话的进程一起结束、首进程退出后 kill 和断线清理仍能结束会话成员、正在使用的 id 不能复用、`--stdio` 会话断开后子进程被清理、登录环境包含 profile 导出的变量。 @@ -357,8 +357,8 @@ M2 接入“打开远程文件夹”时,SSH 连接的配置界面参考用户 ## 适用范围 -- `rust/lithe-remote-protocol/` -- `rust/lithe-remote-server/` -- `rust/lithe-remote-client/` +- `rust/lithe-remote/src/protocol/` +- `rust/lithe-remote/src/server/` +- `rust/lithe-remote/src/client/` - `shared/contracts/remote-workspace-protocol-v1.md` - `.github/workflows/ci-remote.yml` diff --git a/.agents/skills/write-stable-tests/references/test-reporting.md b/.agents/skills/write-stable-tests/references/test-reporting.md index e5af729bbb..151290ffa0 100644 --- a/.agents/skills/write-stable-tests/references/test-reporting.md +++ b/.agents/skills/write-stable-tests/references/test-reporting.md @@ -42,3 +42,8 @@ run: ```bash node .agents/skills/write-stable-tests/scripts/generate-test-report.mjs ``` + +For feature-gated Rust targets, pass `--no-default-features --features client,server` +to `run-rust-tests-with-timing.mjs` (as in `scripts/verify-remote-workspace.sh`). +The runner forwards these flags to Cargo and records the selected features in +JSON so a passing client-only run cannot be mistaken for server coverage. diff --git a/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs b/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs index faa93b0be2..cc994657df 100755 --- a/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs +++ b/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs @@ -20,6 +20,8 @@ export function parseArguments(arguments_) { const options = { manifest: null, package: null, + features: null, + noDefaultFeatures: false, warnMs: 1000, maxMs: 15000, testBudgets: [], @@ -32,6 +34,11 @@ export function parseArguments(arguments_) { const argument = arguments_[index]; if (argument === "--manifest") options.manifest = arguments_[++index]; else if (argument === "--package") options.package = arguments_[++index]; + else if (argument === "--features") { + options.features = arguments_[++index]; + if (!options.features || options.features.startsWith("--")) throw new Error("--features requires a feature list."); + } + else if (argument === "--no-default-features") options.noDefaultFeatures = true; else if (argument === "--warn-ms") options.warnMs = positiveInteger(arguments_[++index], "--warn-ms"); else if (argument === "--max-ms") options.maxMs = positiveInteger(arguments_[++index], "--max-ms"); else if (argument === "--test-budget") { @@ -132,6 +139,8 @@ export async function run( runner: "rust", manifest: options.manifest, package: options.package, + features: options.features ?? null, + noDefaultFeatures: options.noDefaultFeatures ?? false, warnMs: options.warnMs, maxMs: options.maxMs, testBudgets: options.testBudgets ?? [], @@ -158,6 +167,8 @@ export async function run( "--message-format=json", ]; if (options.package) cargoArguments.push("--package", options.package); + if (options.features) cargoArguments.push("--features", options.features); + if (options.noDefaultFeatures) cargoArguments.push("--no-default-features"); console.log(`Compiling Rust tests from ${options.manifest}`); const buildBudget = timeoutBudget("compilation", options.buildTimeoutMs); diff --git a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs index 9bb7366f9b..73e23886a9 100755 --- a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs +++ b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs @@ -783,7 +783,12 @@ try { "--warn-ms", "10", "--max-ms", "100", "--test-budget", "tests::native_=300", "--test-budget", "tests::native=250", "--test-budget", linkedBudget, + "--no-default-features", "--features", "client,server", ]); + assert.equal(options.features, "client,server"); + assert.equal(options.noDefaultFeatures, true); + assert.throws(() => parseRustTimingArguments(["--manifest", "Cargo.toml", "--features"])); + assert.throws(() => parseRustTimingArguments(["--manifest", "Cargo.toml", "--features", "--no-default-features"])); for (const budget of ["=300", "tests::native_=0", "tests::native_=5", "invalid"]) { assert.throws(() => parseRustTimingArguments(["--manifest", "Cargo.toml", "--test-budget", budget])); } @@ -793,6 +798,8 @@ try { let durationMs = 0; let timedOut = false; if (onStdoutLine) { + assert.ok(args.includes("--no-default-features")); + assert.equal(args[args.indexOf("--features") + 1], "client,server"); onStdoutLine(JSON.stringify({ reason: "compiler-artifact", profile: { test: true }, executable: path.join(budgetFixtureRoot, "fake-tests"), manifest_path: options.manifest, target: { name: "budget-fixture" } })); @@ -809,6 +816,8 @@ try { }; await assert.rejects(runRustTestsWithTiming(options, { runProcessImpl, now: () => currentTime }), /1 Rust test/); const result = JSON.parse(readFileSync(report, "utf8")); + assert.equal(result.features, "client,server"); + assert.equal(result.noDefaultFeatures, true); assert.deepEqual(result.tests.map(({ status, maxMs }) => ({ status, maxMs })), [ { status: "passed", maxMs: 100 }, { status: "passed", maxMs: 300 }, { status: "passed", maxMs: 30000 }, { status: "timeout", maxMs: 300 }, diff --git a/.github/workflows/ci-remote.yml b/.github/workflows/ci-remote.yml index 440a437d70..997738214b 100644 --- a/.github/workflows/ci-remote.yml +++ b/.github/workflows/ci-remote.yml @@ -99,7 +99,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - targets: x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, aarch64-apple-darwin, x86_64-apple-darwin + targets: x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, aarch64-apple-darwin, x86_64-apple-darwin, x86_64-pc-windows-msvc - name: Restore Cargo dependencies uses: actions/cache@v6 @@ -112,7 +112,7 @@ jobs: - name: Check formatting, lints and remote server targets run: ./scripts/verify-remote-workspace.sh --static - - name: Test remote workspace crates + - name: Test remote workspace client, protocol and server run: ./scripts/verify-remote-workspace.sh --tests - name: Upload test timing report diff --git a/docs/ci-builds.md b/docs/ci-builds.md index f87013a0ec..c27d120b17 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -385,7 +385,8 @@ HTTP/SSE 响应体和 stdio 元数据限制为 2 MiB,原生文件由现有平 用户明确运行包命令可能产生的 npm/uv 缓存归上游运行时管理,不属于可跨工作树复用的 Lithe 构建资源。 随 App 附带的远端工作区服务登记为 `remote-server-artifacts`。`scripts/build-remote-servers.sh` -每次打包都用本工作树源码重新构建两个 Linux musl 静态版(约 25 秒),输出到 +每次打包都从 `rust/lithe-remote/` 用 `--no-default-features --features server` +只构建 `lithe-remote-server` 可执行文件,不编译客户端;两个 Linux musl 静态版输出到 `rust/target/remote-servers/`,再复制到 `Contents/Resources/RemoteServers/`;构建机需要 `x86_64-unknown-linux-musl` 和 `aarch64-unknown-linux-musl` 两个 Rust 目标。清单只记录服务版本, 不带工具链或源码身份,所以不跨工作树复用。运行时只读取这份副本并上传给 SSH 主机, diff --git a/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift index b4453ba8dd..965113c52a 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift @@ -34,8 +34,7 @@ final class RemoteDirectoryObservation: DirectoryChangeSource, @unchecked Sendab stop() let current = generation for root in configuration.physicalRoots { - // Git common directories may live outside the workspace. The - // Core Git engine already returns those as absolute host paths. + // Git common directories may use the binding's sibling namespace. let remoteRoot = registry.resolve(root)?.remotePath ?? root.path let transport = transport let session = binding.sessionID @@ -79,12 +78,7 @@ final class RemoteDirectoryObservation: DirectoryChangeSource, @unchecked Sendab batch.gitStateMayHaveChanged = overflow var workspacePaths = Set() for path in paths { - let hostRoot = binding.remoteRoot.hasSuffix("/") ? binding.remoteRoot : binding.remoteRoot + "/" - let url: URL - if path == binding.remoteRoot { url = binding.root } - else if path.hasPrefix(hostRoot) { - url = binding.root.appendingPathComponent(String(path.dropFirst(hostRoot.count))).standardizedFileURL - } else { url = URL(fileURLWithPath: path).standardizedFileURL } + let url = binding.localURL(forRemotePath: path) if configuration.revalidatesIgnoredDirectory(url, isStructuralChange: true) { batch.requiresFullRescan = true batch.gitStateMayHaveChanged = true diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index 6fe5502083..72c666acb9 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -14,6 +14,22 @@ struct RemoteWorkspaceBinding: Sendable { let remoteRoot: String let sessionID: String let connectionID: UUID + + /// Virtual sibling namespace for host paths outside the opened folder, + /// including parent repositories and canonical symlink targets. This must + /// match Rust's Host::host_paths_root; no files are materialized here. + var hostPathsRoot: URL { + root.standardizedFileURL.appendingPathExtension("lithe-remote-host") + } + + func localURL(forRemotePath path: String) -> URL { + if path == remoteRoot { return root } + let prefix = remoteRoot.hasSuffix("/") ? remoteRoot : remoteRoot + "/" + if path.hasPrefix(prefix) { + return root.appendingPathComponent(String(path.dropFirst(prefix.count))).standardizedFileURL + } + return hostPathsRoot.appendingPathComponent(String(path.dropFirst())).standardizedFileURL + } } extension Notification.Name { @@ -54,6 +70,11 @@ final class RemoteWorkspaceRegistry: @unchecked Sendable { let path = url.standardizedFileURL.path return lock.withLock { for (root, binding) in bindings.sorted(by: { $0.key.count > $1.key.count }) { + let hostRoot = binding.hostPathsRoot.path + if path == hostRoot { return (binding, "/") } + if path.hasPrefix(hostRoot + "/") { + return (binding, String(path.dropFirst(hostRoot.count))) + } if path == root { return (binding, binding.remoteRoot) } let prefix = root.hasSuffix("/") ? root : root + "/" guard path.hasPrefix(prefix) else { continue } diff --git a/macos/Tests/LitheTests/RemoteConnectionsTests.swift b/macos/Tests/LitheTests/RemoteConnectionsTests.swift index 1c8d50146a..fbc3c8459e 100644 --- a/macos/Tests/LitheTests/RemoteConnectionsTests.swift +++ b/macos/Tests/LitheTests/RemoteConnectionsTests.swift @@ -337,6 +337,52 @@ struct RemoteWorkspaceRoutingTests { #expect(registry.resolve(URL(fileURLWithPath: "/tmp/lithe-tests/RemoteWorkspaces/c/h/application")) == nil) } + @Test + func externalRepositoryURLsKeepTheOriginalHostInSubsequentBridgeRequests() throws { + let (_, _, _, registry) = routing() + let binding = try #require(registry.binding(forRoot: root)) + let parent = binding.localURL(forRemotePath: "/srv") + #expect(parent.path == root.path + ".lithe-remote-host/srv") + #expect(registry.resolve(parent)?.remotePath == "/srv") + #expect(registry.resolve(parent.appendingPathComponent("other.txt"))?.remotePath == "/srv/other.txt") + #expect(registry.resolve(binding.localURL(forRemotePath: "/"))?.remotePath == "/") + #expect(registry.resolve(URL(fileURLWithPath: "/srv")) == nil) + #expect(registry.resolve(URL(fileURLWithPath: root.path + ".lithe-remote-host-neighbor/srv")) == nil) + let bridge = RustCoreBridge(remoteWorkspaces: registry) + for command in ["git.status", "git.write"] { + let selected = try bridge.remoteWorkspace(for: command, payload: ["root": parent.path]) + #expect(selected == RustCoreBridge.RemoteWorkspace(sessionId: "s", localRoot: root.path, remoteRoot: "/srv/app")) + } + let selected = try bridge.remoteWorkspace(for: "git.status", payload: ["repositoryRoots": [root.path, parent.path]]) + #expect(selected?.sessionId == "s") + #expect(throws: RustCoreBridge.CoreCallError.self) { + try bridge.remoteWorkspace(for: "git.status", payload: ["repositoryRoots": [parent.path, "/srv"]]) + } + _ = registry.unregister(root: root) + #expect(registry.resolve(parent) == nil) + } + + @Test + func theSameExternalPathOnTwoHostsCannotShareABridgeRequest() throws { + let (_, _, _, registry) = routing() + let first = try #require(registry.binding(forRoot: root)) + let second = RemoteWorkspaceBinding(root: URL(fileURLWithPath: "/fixture/other-host/app"), + remoteRoot: "/srv/app", sessionID: "other", connectionID: UUID()) + registry.register(second) + let a = first.localURL(forRemotePath: "/srv/releases/repo") + let b = second.localURL(forRemotePath: "/srv/releases/repo") + #expect(a != b) + #expect(registry.resolve(a)?.binding.sessionID == "s") + #expect(registry.resolve(b)?.binding.sessionID == "other") + let bridge = RustCoreBridge(remoteWorkspaces: registry) + #expect(throws: RustCoreBridge.CoreCallError.self) { + try bridge.remoteWorkspace(for: "git.status", payload: ["repositoryRoots": [a.path, b.path]]) + } + let selected = try bridge.remoteWorkspace(for: "git.write", payload: ["root": b.path]) + #expect(selected?.sessionId == "other") + #expect(selected?.remoteRoot == "/srv/app") + } + @Test func remoteIndexLifecycleReachesTheHostAwareCoreAdapter() { final class IndexOperations: WorkspaceOperations, @unchecked Sendable { diff --git a/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift b/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift index c1768bc65e..dedb6f23ec 100644 --- a/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift +++ b/macos/Tests/LitheTests/RemoteDirectoryObservationTests.swift @@ -5,6 +5,35 @@ import Testing @Suite("Remote directory observation") @MainActor struct RemoteDirectoryObservationTests { + @Test + func externalGitMetadataUsesHostPathsAndMapsEventsBackToTheSameNamespace() async throws { + let root = URL(fileURLWithPath: "/fixture/placeholder") + let registry = RemoteWorkspaceRegistry() + let binding = RemoteWorkspaceBinding(root: root, remoteRoot: "/srv/repo/src", sessionID: "one", connectionID: UUID()) + registry.register(binding) + let repository = binding.localURL(forRemotePath: "/srv/repo") + let metadata = binding.localURL(forRemotePath: "/srv/repo/.git") + let host = WatchFixture() + let batches = WatchBatches() + let source = RemoteDirectoryObservation( + configuration: DirectoryWatchConfiguration(workspaceRoot: root, gitContext: GitWatchContext( + repositoryRoot: repository, gitDirectory: metadata, gitCommonDirectory: metadata)), + visibilityRules: FileVisibilityRules(hiddenDirectoryNames: [".git"], hiddenFilePatterns: []), + binding: binding, registry: registry, transport: host, onChange: batches.append + ) + defer { source.stop(); host.finish() } + source.start() + #expect(await watchEventually { host.active == 2 }) + #expect(host.requests.sorted() == ["one:/srv/repo", "one:/srv/repo/src"]) + host.emit(.changed(paths: ["/srv/repo/.git/index"], overflow: false)) + #expect(batches.values.last?.gitStateMayHaveChanged == true) + #expect(batches.values.last?.workspacePaths == []) + host.emit(.changed(paths: ["/srv/repo/src/main.rs"], overflow: false)) + #expect(batches.values.last?.workspacePaths == [root.appendingPathComponent("main.rs").path]) + source.stop() + #expect(await watchEventually { host.active == 0 }) + } + @Test func hostEventsMapToWorkspaceURLsAndCancelledGenerationsCannotRefresh() async throws { let root = URL(fileURLWithPath: "/fixture/placeholder") @@ -74,25 +103,26 @@ private final class WatchFixture: RemoteWorkspaceTransport, @unchecked Sendable private var runs = 0 private var recorded: [String] = [] private var callback: (@Sendable (RemoteWatchEvent) -> Void)? - private var continuation: AsyncStream.Continuation? + private var continuations: [UUID: AsyncStream.Continuation] = [:] var active: Int { lock.withLock { runs } } var requests: [String] { lock.withLock { recorded } } func watch(root: String, session: String, onEvent: @escaping @Sendable (RemoteWatchEvent) -> Void) async throws { + let id = UUID() let (stream, continuation) = AsyncStream.makeStream() lock.withLock { runs += 1 recorded.append(session + ":" + root) callback = onEvent - self.continuation = continuation + continuations[id] = continuation } - defer { lock.withLock { runs -= 1; self.continuation = nil } } + defer { lock.withLock { runs -= 1; continuations[id] = nil } } var iterator = stream.makeAsyncIterator() _ = await iterator.next() } func emit(_ event: RemoteWatchEvent) { lock.withLock { callback }?(event) } - func finish() { lock.withLock { continuation }?.finish() } + func finish() { lock.withLock { Array(continuations.values) }.forEach { $0.finish() } } func listDirectory(_ path: String, session id: String) throws -> [RemoteDirectoryEntry] { [] } func readFile(_ path: String, session id: String) throws -> (data: Data, version: RemoteFileVersion) { throw CocoaError(.fileNoSuchFile) } func writeFile(_ data: Data, to path: String, expected: RemoteFileVersion?, session id: String) throws -> RemoteWriteOutcome { .conflict } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index eaa3eeba14..621d7054f3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1480,9 +1480,7 @@ dependencies = [ "lithe-agent-host", "lithe-git-host", "lithe-ide-host", - "lithe-remote-client", - "lithe-remote-protocol", - "lithe-remote-server", + "lithe-remote", "quick-xml", "rand 0.8.7", "regex", @@ -1529,31 +1527,11 @@ dependencies = [ ] [[package]] -name = "lithe-remote-client" -version = "0.1.0" -dependencies = [ - "lithe-remote-protocol", - "serde", - "serde_json", - "sha2", -] - -[[package]] -name = "lithe-remote-protocol" +name = "lithe-remote" version = "0.1.0" dependencies = [ "base64 0.22.1", - "serde", - "serde_json", -] - -[[package]] -name = "lithe-remote-server" -version = "0.1.0" -dependencies = [ "libc", - "lithe-remote-client", - "lithe-remote-protocol", "serde", "serde_json", "sha2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index bf2995801d..3e23cb1cd4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lithe-core", "lithe-git-host", "lithe-agent-host", "lithe-ide-host", "lithe-remote-protocol", "lithe-remote-server", "lithe-remote-client"] +members = ["lithe-core", "lithe-git-host", "lithe-agent-host", "lithe-ide-host", "lithe-remote"] resolver = "2" [profile.release] diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index e8f9602ec4..2ce17db427 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -12,8 +12,7 @@ crate-type = ["rlib", "staticlib", "cdylib"] lithe-git-host = { path = "../lithe-git-host" } lithe-agent-host = { path = "../lithe-agent-host" } lithe-ide-host = { path = "../lithe-ide-host" } -lithe-remote-client = { path = "../lithe-remote-client" } -lithe-remote-protocol = { path = "../lithe-remote-protocol" } +lithe-remote = { path = "../lithe-remote", default-features = false, features = ["client"] } ammonia = "4.1.4" base64 = "0.22" chrono = { version = "0.4", default-features = false, features = ["std"] } @@ -37,4 +36,4 @@ zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" [target.'cfg(unix)'.dev-dependencies] -lithe-remote-server = { path = "../lithe-remote-server" } +lithe-remote = { path = "../lithe-remote", default-features = false, features = ["server"] } diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 0c4c964053..5e42cc7b69 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -116,8 +116,8 @@ pub(crate) fn invocation_plan( let root = decode::request_path(&request.root, "root")?; let platform = crate::remote::workspace::current() .map(|host| match host.connection.session.hello().host.os { - lithe_remote_protocol::methods::OperatingSystem::Linux => Platform::Linux, - lithe_remote_protocol::methods::OperatingSystem::Macos => Platform::Macos, + lithe_remote::protocol::methods::OperatingSystem::Linux => Platform::Linux, + lithe_remote::protocol::methods::OperatingSystem::Macos => Platform::Macos, }) .unwrap_or(request.platform); let invocation = &build_system.invocation; diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 92eca3b2d9..293d2495ef 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -4298,7 +4298,8 @@ fn discover_containing_repository(root: &Path) -> Result, CoreEr if repository_root.is_empty() { return Ok(None); } - let path = canonicalize_or_original(Path::new(repository_root)).map_err(git_path_error)?; + // Discovery must not return an unprojected host path after an IO failure. + let path = canonicalize_simplified(Path::new(repository_root)).map_err(git_path_error)?; Ok(Some(path)) } diff --git a/rust/lithe-core/src/git/remote_history.rs b/rust/lithe-core/src/git/remote_history.rs index 99c9b9793d..daef3f1a63 100644 --- a/rust/lithe-core/src/git/remote_history.rs +++ b/rust/lithe-core/src/git/remote_history.rs @@ -2,8 +2,8 @@ use crate::protocol::{cancellation, CoreError, ErrorCode}; use crate::remote::workspace::{self, Host}; -use lithe_remote_client::session::Event; -use lithe_remote_protocol::methods::{self, OutputStream}; +use lithe_remote::client::session::Event; +use lithe_remote::protocol::methods::{self, OutputStream}; use serde_json::json; use std::collections::VecDeque; use std::sync::mpsc::{Receiver, RecvTimeoutError}; @@ -190,7 +190,7 @@ impl Drop for RemoteHistory { } } -fn failure(error: lithe_remote_client::session::ClientError) -> CoreError { +fn failure(error: lithe_remote::client::session::ClientError) -> CoreError { CoreError::new(ErrorCode::ProcessFailed, "Remote Git history failed") .with_details(error.to_string()) } diff --git a/rust/lithe-core/src/git/remote_process.rs b/rust/lithe-core/src/git/remote_process.rs index c8a42ee348..4c64675546 100644 --- a/rust/lithe-core/src/git/remote_process.rs +++ b/rust/lithe-core/src/git/remote_process.rs @@ -3,7 +3,7 @@ use super::{execution_events, execution_policy, GitProcessOutput}; use crate::protocol::{CoreError, ErrorCode}; use crate::remote::{workspace, workspace_host}; -use lithe_remote_protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use lithe_remote::protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; use std::collections::BTreeMap; use std::path::Path; use std::time::Duration; @@ -252,8 +252,8 @@ fn remote_git_executable( .with_details(error.to_string()) })? } - Err(lithe_remote_client::session::ClientError::Remote(error)) - if error.code == lithe_remote_protocol::ErrorCode::NotFound => + Err(lithe_remote::client::session::ClientError::Remote(error)) + if error.code == lithe_remote::protocol::ErrorCode::NotFound => { continue } diff --git a/rust/lithe-core/src/lsp/interface/remote_process.rs b/rust/lithe-core/src/lsp/interface/remote_process.rs index 9d0a85c27f..a2b454ff98 100644 --- a/rust/lithe-core/src/lsp/interface/remote_process.rs +++ b/rust/lithe-core/src/lsp/interface/remote_process.rs @@ -6,8 +6,8 @@ use super::process::{LspProcessHandle, LspProcessSpec, LspProcessStreams}; use crate::protocol::{cancellation, CoreError, ErrorCode}; use crate::remote::sessions::{request_error, Connection}; use crate::remote::workspace::Host; -use lithe_remote_client::session::{ClientError, Event}; -use lithe_remote_protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; +use lithe_remote::client::session::{ClientError, Event}; +use lithe_remote::protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; use serde_json::json; use serde_json::Value; use std::collections::VecDeque; @@ -310,7 +310,7 @@ impl RemoteProcess { CLEANUP_TIMEOUT, ); if let Err(error) = result { - if !matches!(&error, ClientError::Remote(remote) if remote.code == lithe_remote_protocol::ErrorCode::UnknownResource) + if !matches!(&error, ClientError::Remote(remote) if remote.code == lithe_remote::protocol::ErrorCode::UnknownResource) && !self.connection.session.is_closed() { eprintln!("remote backend cleanup failed: {error}"); @@ -403,7 +403,7 @@ impl RemoteProcess { match outcome { Ok(_) => break, Err(ClientError::Remote(error)) - if error.code == lithe_remote_protocol::ErrorCode::Busy => + if error.code == lithe_remote::protocol::ErrorCode::Busy => { // A full host stdin queue retries only within this frame's deadline. std::thread::park_timeout( diff --git a/rust/lithe-core/src/remote/mod.rs b/rust/lithe-core/src/remote/mod.rs index c2c4e885ab..e41743ef93 100644 --- a/rust/lithe-core/src/remote/mod.rs +++ b/rust/lithe-core/src/remote/mod.rs @@ -1,7 +1,7 @@ //! Remote development connections: checking that a saved SSH connection //! reaches a usable host before any workspace is opened on it. //! -//! The command builds the transport chain with `lithe-remote-client`, runs +//! The command builds the transport chain with `lithe_remote::client`, runs //! its platform probe through the system OpenSSH client, and relays every //! question OpenSSH asks (passwords, key passphrases, one-time codes, unknown //! host keys) to the host UI through the shared AskPass session. Core never @@ -11,8 +11,8 @@ use crate::git::execution_events; use crate::protocol::{CoreError, ErrorCode}; -use lithe_remote_client::bootstrap::{parse_probe, probe_command}; -use lithe_remote_client::{Hop, SshHop, TransportChain}; +use lithe_remote::client::bootstrap::{parse_probe, probe_command}; +use lithe_remote::client::{Hop, SshHop, TransportChain}; use serde::{Deserialize, Serialize}; use std::process::{Command, ExitStatus}; use std::time::{Duration, Instant}; @@ -111,7 +111,7 @@ pub(crate) struct ProbedHost { /// Rust target of the server build for this host, or `None`. server_target: Option<&'static str>, /// Facts for display, such as the distribution and memory. - details: lithe_remote_client::bootstrap::HostDetails, + details: lithe_remote::client::bootstrap::HostDetails, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] diff --git a/rust/lithe-core/src/remote/sessions.rs b/rust/lithe-core/src/remote/sessions.rs index 694a60309b..2dccfbd0ee 100644 --- a/rust/lithe-core/src/remote/sessions.rs +++ b/rust/lithe-core/src/remote/sessions.rs @@ -1,7 +1,7 @@ //! Long-lived remote connections. //! //! `remote.connect` starts one OpenSSH process that probes the host, installs -//! the server when needed and starts it (see `lithe_remote_client::launch`), +//! the server when needed and starts it (see `lithe_remote::client::launch`), //! so the user authenticates once. The connection then stays open in this //! registry until `remote.disconnect`; requests such as `remote.readDir` use //! it by id. OpenSSH runs in its own process tree, which closing a connection @@ -11,10 +11,10 @@ use super::{classify, diagnostics, ssh_hop, ProbeStatus, ProbedHost, SshConnecti use crate::git::execution_events; use crate::protocol::{CoreError, ErrorCode}; use lithe_git_host::ProcessTree; -use lithe_remote_client::launch::{launch, launch_command, LaunchError, Launched}; -use lithe_remote_client::session::{ClientError, ConnectOptions}; -use lithe_remote_client::{Hop, RemoteSession, ServerArtifact, ServerTarget, TransportChain}; -use lithe_remote_protocol::methods; +use lithe_remote::client::launch::{launch, launch_command, LaunchError, Launched}; +use lithe_remote::client::session::{ClientError, ConnectOptions}; +use lithe_remote::client::{Hop, RemoteSession, ServerArtifact, ServerTarget, TransportChain}; +use lithe_remote::protocol::methods; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; use std::io::Read; @@ -566,7 +566,7 @@ pub(crate) fn write_file(request: WriteFileRequest) -> Result Result { Ok(serde_json::json!({ "status": "conflict" })) @@ -674,8 +674,8 @@ pub(crate) fn request_error(session_id: &str, error: ClientError) -> CoreError { match error { ClientError::Remote(error) => { let code = match error.code { - lithe_remote_protocol::ErrorCode::NotFound => ErrorCode::WorkspaceNotFound, - lithe_remote_protocol::ErrorCode::PermissionDenied => ErrorCode::PermissionDenied, + lithe_remote::protocol::ErrorCode::NotFound => ErrorCode::WorkspaceNotFound, + lithe_remote::protocol::ErrorCode::PermissionDenied => ErrorCode::PermissionDenied, _ => ErrorCode::ProcessFailed, }; CoreError::new(code, error.message) diff --git a/rust/lithe-core/src/remote/sessions_integration_tests.rs b/rust/lithe-core/src/remote/sessions_integration_tests.rs index e768f1a6da..39d80fd579 100644 --- a/rust/lithe-core/src/remote/sessions_integration_tests.rs +++ b/rust/lithe-core/src/remote/sessions_integration_tests.rs @@ -28,7 +28,7 @@ impl HostFixture { let socket = client.try_clone().unwrap(); let (finished, done) = mpsc::channel(); let worker = std::thread::spawn(move || { - let result = lithe_remote_server::serve_stdio(server.try_clone().unwrap(), server); + let result = lithe_remote::server::serve_stdio(server.try_clone().unwrap(), server); let _ = finished.send(()); result.unwrap(); }); @@ -66,9 +66,13 @@ impl HostFixture { } fn request(&self, command: &str, payload: Value) -> Value { + self.request_at(&self.root.join("host"), command, payload) + } + + fn request_at(&self, remote_root: &std::path::Path, command: &str, payload: Value) -> Value { let response: Value = serde_json::from_str(&crate::execute_json(&json!({ "id": nonce(), "command": command, "timeoutMilliseconds": 30_000, - "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": self.root.join("host")}, + "remoteWorkspace": {"sessionId": self.id, "localRoot": self.root.join("placeholder"), "remoteRoot": remote_root}, "payload": payload, }).to_string())).unwrap(); assert_eq!(response["ok"], true, "{command}: {response}"); @@ -334,6 +338,89 @@ fn real_remote_git_status_stage_commit_and_history_never_touch_the_placeholder() assert!(host.root.join("host/.git/index").is_file()); } +#[test] +fn remote_parent_repository_discovery_keeps_identity_for_status_and_stage() { + discovered_repository_keeps_host_identity(false); +} + +#[test] +fn remote_symlink_repository_discovery_keeps_identity_for_status_and_stage() { + discovered_repository_keeps_host_identity(true); +} + +fn discovered_repository_keeps_host_identity(symlink: bool) { + let host = HostFixture::new(); + host.git(&["init", "-q", "-b", "main"]); + let repository = host.root.join("host"); + std::fs::write(repository.join("remote.txt"), "remote only\n").unwrap(); + let remote_root = if symlink { + let link = host.root.join("opened-link"); + std::os::unix::fs::symlink(&repository, &link).unwrap(); + link + } else { + let child = repository.join("src"); + std::fs::create_dir(&child).unwrap(); + child + }; + let placeholder = host.root.join("placeholder"); + let host_paths = host.root.join("placeholder.lithe-remote-host"); + let expected = host_paths.join(repository.strip_prefix("/").unwrap()); + let discovered = host.request_at( + &remote_root, + "workspace.repositories", + json!({"root": placeholder}), + ); + assert_eq!(discovered["repositories"], json!([{"path": expected}])); + let root = discovered["repositories"][0]["path"].as_str().unwrap(); + + // Simulate the bridge's next request using only the returned repository URL. + // A real local repository at that URL catches any placeholder fallback. + std::fs::create_dir_all(&expected).unwrap(); + let local_git = |arguments: &[&str]| { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": nonce(), "command": "git.command", "timeoutMilliseconds": 10_000, + "payload": {"root": expected, "arguments": arguments} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + }; + local_git(&["init", "-q", "-b", "main"]); + std::fs::write(expected.join("local.txt"), "local sentinel\n").unwrap(); + local_git(&["add", "local.txt"]); + let index = std::fs::read(expected.join(".git/index")).unwrap(); + let routed = |command, payload| { + assert!(std::path::Path::new(root).starts_with(&host_paths)); + host.request_at(&remote_root, command, payload) + }; + let status = routed( + "git.status", + json!({"root": root, "repositoryRoots": [root]}), + ); + assert_eq!(status["changes"][0]["path"], "remote.txt", "{status}"); + let context = routed("git.watchContext", json!({"root": root})); + assert_eq!(context["repositoryRoot"], json!(expected)); + assert_eq!(context["gitCommonDirectory"], json!(expected.join(".git"))); + let staged = routed( + "git.write", + json!({"root": root, "operation": "stage", "paths": ["remote.txt"]}), + ); + assert_eq!(staged["exitCode"], 0, "{staged}"); + assert_eq!( + host.git(&["diff", "--cached", "--name-only"])["stdout"], + "remote.txt\n" + ); + assert_eq!(std::fs::read(expected.join(".git/index")).unwrap(), index); + assert_eq!( + std::fs::read_to_string(expected.join("local.txt")).unwrap(), + "local sentinel\n" + ); + assert_eq!(std::fs::read_dir(placeholder).unwrap().count(), 0); +} + #[test] fn remote_git_snapshot_selected_commit_and_unsaved_line_history_preserve_the_real_index() { let host = HostFixture::new(); @@ -578,11 +665,11 @@ fn captured_host_process_preserves_binary_streams_and_cleans_up_limit_failures() ) .unwrap(); assert_eq!( - lithe_remote_protocol::methods::decode_bytes(output["stdout"].as_str().unwrap()).unwrap(), + lithe_remote::protocol::methods::decode_bytes(output["stdout"].as_str().unwrap()).unwrap(), b"a\0b" ); assert_eq!( - lithe_remote_protocol::methods::decode_bytes(output["stderr"].as_str().unwrap()).unwrap(), + lithe_remote::protocol::methods::decode_bytes(output["stderr"].as_str().unwrap()).unwrap(), b"err" ); assert_eq!(output["exitCode"], 7); @@ -737,6 +824,21 @@ fn plugin_protocol_maps_owned_uris_without_rewriting_source_text() { let remote = url::Url::from_file_path(host.root.join("host/a space.java")) .unwrap() .to_string(); + let external_path = host.root.join("outside/a space.java"); + let external_uri = url::Url::from_file_path(&external_path) + .unwrap() + .to_string(); + let external_local = owner.map_uri(&external_uri, false).unwrap(); + assert_eq!( + url::Url::parse(&external_local) + .unwrap() + .to_file_path() + .unwrap(), + host.root + .join("placeholder.lithe-remote-host") + .join(external_path.strip_prefix("/").unwrap()) + ); + assert_eq!(owner.map_uri(&external_local, true), Some(external_uri)); let value = json!({"method": "textDocument/didOpen", "params": {"textDocument": {"uri": local, "text": format!("// {local}"), "languageId": "java", "version": 1}}}); let mapped: Value = serde_json::from_str( diff --git a/rust/lithe-core/src/remote/upload.rs b/rust/lithe-core/src/remote/upload.rs index b699f58f9f..a8e7e9fff4 100644 --- a/rust/lithe-core/src/remote/upload.rs +++ b/rust/lithe-core/src/remote/upload.rs @@ -4,7 +4,7 @@ use super::sessions::{lookup, request_error}; use super::workspace_host::request_cancellable; use crate::protocol::{cancellation, CoreError, ErrorCode}; -use lithe_remote_protocol::methods; +use lithe_remote::protocol::methods; use serde::Deserialize; use serde_json::{json, Value}; use std::io::Read; @@ -126,8 +126,8 @@ pub(crate) fn upload(request: Request) -> Result { json!({"uploadId": id}), Duration::from_secs(2), ) { - if !matches!(&error, lithe_remote_client::session::ClientError::Remote(remote) - if remote.code == lithe_remote_protocol::ErrorCode::NotFound) + if !matches!(&error, lithe_remote::client::session::ClientError::Remote(remote) + if remote.code == lithe_remote::protocol::ErrorCode::NotFound) { eprintln!("remote upload abort failed; connection staging will expire"); } diff --git a/rust/lithe-core/src/remote/workspace.rs b/rust/lithe-core/src/remote/workspace.rs index 2c6a34b8fc..54471d17b8 100644 --- a/rust/lithe-core/src/remote/workspace.rs +++ b/rust/lithe-core/src/remote/workspace.rs @@ -58,7 +58,10 @@ impl Scope { "This command has no remote workspace adapter", )); } - if !valid_root(&workspace.local_root) || !valid_root(&workspace.remote_root) { + if !valid_root(&workspace.local_root) + || workspace.local_root.file_name().is_none() + || !valid_root(&workspace.remote_root) + { return Err(CoreError::new( ErrorCode::InvalidRequest, "Workspace roots must be absolute without parent components", @@ -102,7 +105,7 @@ pub(crate) fn is_remote() -> bool { } impl Host { - /// Maps only file URIs contained by this workspace, preserving URI escaping. + /// Maps schema-owned file URIs while preserving host identity and escaping. pub(crate) fn map_uri(&self, value: &str, outbound: bool) -> Option { let uri = url::Url::parse(value).ok()?; if uri.scheme() != "file" @@ -126,6 +129,9 @@ impl Host { .map(|uri| uri.to_string()) } pub(crate) fn remote_path(&self, path: &Path) -> PathBuf { + if let Ok(suffix) = path.strip_prefix(self.host_paths_root()) { + return Path::new("/").join(suffix); + } if path == self.workspace.local_root { return self.workspace.remote_root.clone(); } @@ -140,7 +146,24 @@ impl Host { } path.strip_prefix(&self.workspace.remote_root) .map(|suffix| self.workspace.local_root.join(suffix)) - .unwrap_or_else(|_| path.to_owned()) + .unwrap_or_else(|_| { + path.strip_prefix("/") + .map(|suffix| self.host_paths_root().join(suffix)) + .unwrap_or_else(|_| path.to_owned()) + }) + } + + fn host_paths_root(&self) -> PathBuf { + // A sibling namespace cannot collide with files in the opened folder. + // Keep this projection in sync with RemoteWorkspaceBinding on macOS. + let mut name = self + .workspace + .local_root + .file_name() + .unwrap() + .to_os_string(); + name.push(".lithe-remote-host"); + self.workspace.local_root.with_file_name(name) } pub(super) fn identity(&self) -> &str { diff --git a/rust/lithe-core/src/remote/workspace_host.rs b/rust/lithe-core/src/remote/workspace_host.rs index 20594ef1cd..dda4a85785 100644 --- a/rust/lithe-core/src/remote/workspace_host.rs +++ b/rust/lithe-core/src/remote/workspace_host.rs @@ -7,8 +7,8 @@ use super::sessions::{lookup, request_error}; use crate::git::execution_events; use crate::protocol::{cancellation, CoreError, ErrorCode}; -use lithe_remote_client::session::{ClientError, Event, RemoteSession}; -use lithe_remote_protocol::methods; +use lithe_remote::client::session::{ClientError, Event, RemoteSession}; +use lithe_remote::protocol::methods; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::VecDeque; @@ -392,7 +392,7 @@ fn receive_events( } } if let Some(pending) = &writing { - match lithe_remote_client::session::PendingRequest::poll(pending, Duration::ZERO) { + match lithe_remote::client::session::PendingRequest::poll(pending, Duration::ZERO) { Ok(Some(_)) => { input = if write_count == 0 { None @@ -403,7 +403,7 @@ fn receive_events( } Ok(None) => {} Err(ClientError::Remote(error)) - if error.code == lithe_remote_protocol::ErrorCode::Busy => + if error.code == lithe_remote::protocol::ErrorCode::Busy => { writing = None } @@ -412,8 +412,8 @@ fn receive_events( Err(ClientError::Remote(error)) if matches!( error.code, - lithe_remote_protocol::ErrorCode::UnknownResource - | lithe_remote_protocol::ErrorCode::InvalidRequest + lithe_remote::protocol::ErrorCode::UnknownResource + | lithe_remote::protocol::ErrorCode::InvalidRequest ) => { input = None; diff --git a/rust/lithe-core/src/remote/workspace_host/tests.rs b/rust/lithe-core/src/remote/workspace_host/tests.rs index b3223ee777..9454eb4722 100644 --- a/rust/lithe-core/src/remote/workspace_host/tests.rs +++ b/rust/lithe-core/src/remote/workspace_host/tests.rs @@ -3,9 +3,9 @@ #![cfg(unix)] use super::*; -use lithe_remote_client::ConnectOptions; -use lithe_remote_protocol::frame::{read_frame, write_frame}; -use lithe_remote_protocol::{Message, PREAMBLE}; +use lithe_remote::client::ConnectOptions; +use lithe_remote::protocol::frame::{read_frame, write_frame}; +use lithe_remote::protocol::{Message, PREAMBLE}; use std::io::Write; use std::net::Shutdown; use std::os::unix::net::UnixStream; diff --git a/rust/lithe-core/src/remote/workspace_io.rs b/rust/lithe-core/src/remote/workspace_io.rs index b6a101590a..56b629a50e 100644 --- a/rust/lithe-core/src/remote/workspace_io.rs +++ b/rust/lithe-core/src/remote/workspace_io.rs @@ -1,8 +1,8 @@ //! Filesystem adapter for workspace engines, with no local fallback on remote errors. use super::workspace::{self, Host}; -use lithe_remote_client::session::ClientError; -use lithe_remote_protocol::{methods, ErrorCode}; +use lithe_remote::client::session::ClientError; +use lithe_remote::protocol::{methods, ErrorCode}; use serde::de::DeserializeOwned; use serde_json::{json, Value}; use std::io::{self, Read}; diff --git a/rust/lithe-remote-client/Cargo.toml b/rust/lithe-remote-client/Cargo.toml deleted file mode 100644 index 96c3dfb73c..0000000000 --- a/rust/lithe-remote-client/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "lithe-remote-client" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -description = "Transport chains, server bootstrap and sessions for Lithe remote workspaces" -publish = false - -[dependencies] -lithe-remote-protocol = { path = "../lithe-remote-protocol" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" diff --git a/rust/lithe-remote-protocol/Cargo.toml b/rust/lithe-remote-protocol/Cargo.toml deleted file mode 100644 index 569bf20afa..0000000000 --- a/rust/lithe-remote-protocol/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "lithe-remote-protocol" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -description = "Wire protocol between Lithe and its remote workspace server" -publish = false - -[dependencies] -base64 = "0.22" -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/rust/lithe-remote-server/Cargo.toml b/rust/lithe-remote-server/Cargo.toml deleted file mode 100644 index 99ae6c0165..0000000000 --- a/rust/lithe-remote-server/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "lithe-remote-server" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" -description = "Workspace server that Lithe starts on SSH hosts and in containers" -publish = false - -# The server runs on Linux and macOS hosts only. Linux builds target musl so a -# single static binary works across distributions and minimal images. -[dependencies] -libc = "0.2" -lithe-remote-protocol = { path = "../lithe-remote-protocol" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -trash = { version = "5.2.6", default-features = false, features = ["chrono"] } - -[dev-dependencies] -lithe-remote-client = { path = "../lithe-remote-client" } diff --git a/rust/lithe-remote-server/src/ports.rs b/rust/lithe-remote-server/src/ports.rs deleted file mode 100644 index ce475bc7d2..0000000000 --- a/rust/lithe-remote-server/src/ports.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Listening ports on the remote, for automatic port forwarding. - -use crate::platform; -use crate::session::Session; -use crate::to_value; -use lithe_remote_protocol::methods::ListeningPortsResult; -use lithe_remote_protocol::RemoteError; -use serde_json::Value; -use std::sync::Arc; - -pub(crate) fn handle(_session: &Arc) -> Result { - let mut ports = platform::listening_ports().map_err(|error| crate::fs::io_error(error, ""))?; - ports.sort(); - ports.dedup(); - to_value(ListeningPortsResult { ports }) -} diff --git a/rust/lithe-remote/Cargo.toml b/rust/lithe-remote/Cargo.toml new file mode 100644 index 0000000000..620bcb958e --- /dev/null +++ b/rust/lithe-remote/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "lithe-remote" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Protocol, client transports and host server for Lithe remote workspaces" +publish = false +autotests = false + +[features] +default = ["client"] +client = ["dep:sha2"] +# Host adapters must not become desktop client dependencies. Linux releases +# target musl so the same static executable works in minimal containers. +server = ["dep:libc", "dep:sha2", "dep:trash"] + +[dependencies] +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { version = "0.10", optional = true } +libc = { version = "0.2", optional = true } +trash = { version = "5.2.6", default-features = false, features = ["chrono"], optional = true } + +[[bin]] +name = "lithe-remote-server" +path = "src/bin/lithe-remote-server.rs" +required-features = ["server"] + +[[test]] +name = "server" +required-features = ["client", "server"] + +[[test]] +name = "processes" +required-features = ["client", "server"] + +[[test]] +name = "watch_and_walk" +required-features = ["client", "server"] + +[[test]] +name = "terminals" +required-features = ["client", "server"] + +[[test]] +name = "run_directories" +required-features = ["client", "server"] + +[[test]] +name = "tunnels" +required-features = ["client", "server"] + +[[test]] +name = "persistence" +required-features = ["client", "server"] diff --git a/rust/lithe-remote-server/src/main.rs b/rust/lithe-remote/src/bin/lithe-remote-server.rs similarity index 86% rename from rust/lithe-remote-server/src/main.rs rename to rust/lithe-remote/src/bin/lithe-remote-server.rs index bc8980993a..9bca438dfc 100644 --- a/rust/lithe-remote-server/src/main.rs +++ b/rust/lithe-remote/src/bin/lithe-remote-server.rs @@ -10,7 +10,7 @@ //! Nothing but protocol bytes may reach standard output in `--stdio` and //! `--connect` modes; diagnostics go to standard error. -use lithe_remote_server::PersistentOptions; +use lithe_remote::server::PersistentOptions; use std::io::{self, Write}; use std::path::PathBuf; use std::process::ExitCode; @@ -21,21 +21,21 @@ fn main() -> ExitCode { let arguments: Vec = std::env::args().skip(1).collect(); let arguments: Vec<&str> = arguments.iter().map(String::as_str).collect(); let result = match arguments.as_slice() { - ["--stdio"] => lithe_remote_server::serve_stdio(io::stdin(), io::stdout()), + ["--stdio"] => lithe_remote::server::serve_stdio(io::stdin(), io::stdout()), ["--connect", key, rest @ ..] => { - persistent(key, rest).and_then(|options| lithe_remote_server::run_proxy(&options)) + persistent(key, rest).and_then(|options| lithe_remote::server::run_proxy(&options)) } ["--daemon", key, rest @ ..] => { - persistent(key, rest).and_then(|options| lithe_remote_server::run_daemon(&options)) + persistent(key, rest).and_then(|options| lithe_remote::server::run_daemon(&options)) } - ["--identity"] => lithe_remote_server::identity().and_then(|identity| { + ["--identity"] => lithe_remote::server::identity().and_then(|identity| { let mut stdout = io::stdout(); serde_json::to_writer(&mut stdout, &identity) .map_err(io::Error::from) .and_then(|()| writeln!(stdout)) }), ["--version"] => writeln!(io::stdout(), "{}", env!("CARGO_PKG_VERSION")), - ["--print-environment"] => lithe_remote_server::print_environment(), + ["--print-environment"] => lithe_remote::server::print_environment(), _ => { eprintln!("{USAGE}"); return ExitCode::from(2); diff --git a/rust/lithe-remote-client/src/api.rs b/rust/lithe-remote/src/client/api.rs similarity index 99% rename from rust/lithe-remote-client/src/api.rs rename to rust/lithe-remote/src/client/api.rs index b1e844130e..d73cff0789 100644 --- a/rust/lithe-remote-client/src/api.rs +++ b/rust/lithe-remote/src/client/api.rs @@ -6,8 +6,8 @@ //! running until it is stopped explicitly, which is what lets terminals and //! builds survive a reconnect. -use crate::session::{ClientError, Event, RemoteSession}; -use lithe_remote_protocol::methods::{ +use crate::client::session::{ClientError, Event, RemoteSession}; +use crate::protocol::methods::{ self, decode_bytes, encode_bytes, CopyParams, CopyResult, CreateDirParams, EnvironmentResolveParams, EnvironmentResolveResult, EnvironmentSource, FileMetadata, FileVersion, KillParams, ListeningPort, ListeningPortsResult, OutputStream, PathParams, @@ -20,7 +20,7 @@ use lithe_remote_protocol::methods::{ WalkStartParams, WatchChangedEvent, WatchErrorEvent, WatchParams, WatchStartParams, WatchStartResult, WriteFileParams, MAX_TRANSFER_BYTES, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::{json, Value}; diff --git a/rust/lithe-remote-client/src/bootstrap.rs b/rust/lithe-remote/src/client/bootstrap.rs similarity index 99% rename from rust/lithe-remote-client/src/bootstrap.rs rename to rust/lithe-remote/src/client/bootstrap.rs index 739b3f1ad5..4f2015fd5e 100644 --- a/rust/lithe-remote-client/src/bootstrap.rs +++ b/rust/lithe-remote/src/client/bootstrap.rs @@ -9,10 +9,10 @@ //! the artifact this client ships. Each version lives in its own directory, so //! clients of different releases can share a host. -use crate::command::{self, CommandOutput}; -use crate::transport::{TransportChain, TransportError}; -use lithe_remote_protocol::methods::ServerIdentity; -use lithe_remote_protocol::PROTOCOL_VERSION; +use crate::client::command::{self, CommandOutput}; +use crate::client::transport::{TransportChain, TransportError}; +use crate::protocol::methods::ServerIdentity; +use crate::protocol::PROTOCOL_VERSION; use sha2::{Digest, Sha256}; use std::fmt; use std::fs::File; diff --git a/rust/lithe-remote-client/src/command.rs b/rust/lithe-remote/src/client/command.rs similarity index 100% rename from rust/lithe-remote-client/src/command.rs rename to rust/lithe-remote/src/client/command.rs diff --git a/rust/lithe-remote-client/src/forward.rs b/rust/lithe-remote/src/client/forward.rs similarity index 94% rename from rust/lithe-remote-client/src/forward.rs rename to rust/lithe-remote/src/client/forward.rs index 5e40660be1..b40615becb 100644 --- a/rust/lithe-remote-client/src/forward.rs +++ b/rust/lithe-remote/src/client/forward.rs @@ -5,9 +5,9 @@ //! `http://127.0.0.1:` in a browser reaches a server that only //! listens on the remote machine's loopback interface. -use crate::api::TunnelEvent; -use crate::session::{ClientError, RemoteSession}; -use lithe_remote_protocol::methods::TunnelTarget; +use crate::client::api::TunnelEvent; +use crate::client::session::{ClientError, RemoteSession}; +use crate::protocol::methods::TunnelTarget; use std::io::{self, Read, Write}; use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -63,9 +63,7 @@ impl PortForward { let target = target.clone(); thread::spawn(move || { if let Err(error) = bridge(&session, connection, target) { - eprintln!( - "lithe-remote-client: forwarded connection failed: {error}" - ); + eprintln!("lithe-remote: forwarded connection failed: {error}"); } }); } diff --git a/rust/lithe-remote-client/src/launch.rs b/rust/lithe-remote/src/client/launch.rs similarity index 93% rename from rust/lithe-remote-client/src/launch.rs rename to rust/lithe-remote/src/client/launch.rs index 64d6c58b47..30aea776b8 100644 --- a/rust/lithe-remote-client/src/launch.rs +++ b/rust/lithe-remote/src/client/launch.rs @@ -1,6 +1,6 @@ //! Probing, installing and starting the server over one connection. //! -//! [`crate::bootstrap::Bootstrapper`] runs one command per step, so an SSH +//! [`crate::client::bootstrap::Bootstrapper`] runs one command per step, so an SSH //! host that asks for a password asks once per step. This module does all //! steps inside one remote shell on one stream instead: //! @@ -11,13 +11,13 @@ //! receives exactly `size` bytes, installs them under a temporary name and //! verifies the result; //! 4. the shell `exec`s the server, whose preamble starts the protocol on the -//! same stream, ready for [`crate::RemoteSession::from_streams`]. +//! same stream, ready for [`crate::client::RemoteSession::from_streams`]. //! //! The script crosses SSH hops, so it is a single line without `'`, `\` or -//! `!` (see [`crate::transport`]). Install directories match the bootstrapper's, +//! `!` (see [`crate::client::transport`]). Install directories match the bootstrapper's, //! so both paths reuse each other's installs. -use crate::bootstrap::{parse_probe, ProbeReport, ServerArtifact, INSTALL_HASH_PREFIX}; +use crate::client::bootstrap::{parse_probe, ProbeReport, ServerArtifact, INSTALL_HASH_PREFIX}; use std::fmt; use std::io::{self, Read, Write}; @@ -31,7 +31,7 @@ const NOISE_LIMIT: usize = 64 * 1024; /// `$1` is the install root; empty means `$HOME/.lithe-server`. The remaining /// arguments are passed to the server, for example `--stdio`. const LAUNCH_SCRIPT: &str = concat!( - crate::bootstrap::host_details_script!(), + crate::client::bootstrap::host_details_script!(), "echo LITHE-PROBE-BEGIN; uname -s; uname -m; echo \"$HOME\"; echo LITHE-PROBE-END; ", "read version sha size name || exit 90; ", "root=\"$1\"; [ -n \"$root\" ] || root=\"$HOME/.lithe-server\"; ", @@ -194,7 +194,9 @@ mod tests { assert!(!LAUNCH_SCRIPT .chars() .any(|character| matches!(character, '\'' | '\\' | '!') || character.is_control())); - let chain = crate::TransportChain::new(vec![crate::Hop::Ssh(crate::SshHop::new("box"))]); + let chain = crate::client::TransportChain::new(vec![crate::client::Hop::Ssh( + crate::client::SshHop::new("box"), + )]); chain.command(&launch_command(None, &["--stdio"])).unwrap(); } } diff --git a/rust/lithe-remote-client/src/lib.rs b/rust/lithe-remote/src/client/mod.rs similarity index 100% rename from rust/lithe-remote-client/src/lib.rs rename to rust/lithe-remote/src/client/mod.rs diff --git a/rust/lithe-remote-client/src/session.rs b/rust/lithe-remote/src/client/session.rs similarity index 99% rename from rust/lithe-remote-client/src/session.rs rename to rust/lithe-remote/src/client/session.rs index 32541a876e..da6fd8552d 100644 --- a/rust/lithe-remote-client/src/session.rs +++ b/rust/lithe-remote/src/client/session.rs @@ -14,12 +14,12 @@ //! it, so a peer that stops reading blocks only that thread, and every request //! still ends at its own deadline. -use crate::transport::{TransportChain, TransportError}; -use lithe_remote_protocol::frame::{read_frame, skip_preamble, write_frame}; -use lithe_remote_protocol::methods::{ +use crate::client::transport::{TransportChain, TransportError}; +use crate::protocol::frame::{read_frame, skip_preamble, write_frame}; +use crate::protocol::methods::{ self, AckParams, HelloParams, HelloResult, ResumeParams, ResumeResult, }; -use lithe_remote_protocol::{FrameError, Message, RemoteError, PROTOCOL_VERSION}; +use crate::protocol::{FrameError, Message, RemoteError, PROTOCOL_VERSION}; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Value; diff --git a/rust/lithe-remote-client/src/transport.rs b/rust/lithe-remote/src/client/transport.rs similarity index 99% rename from rust/lithe-remote-client/src/transport.rs rename to rust/lithe-remote/src/client/transport.rs index fb84dfebd6..ac08ee103d 100644 --- a/rust/lithe-remote-client/src/transport.rs +++ b/rust/lithe-remote/src/client/transport.rs @@ -423,6 +423,7 @@ mod tests { // an inner hop generates must survive one more round of parsing. A fake // `ssh` runs that string with a real shell, for every supported login // shell installed here. + #[cfg(unix)] #[test] fn nested_ssh_hops_deliver_arguments_unchanged() { use std::os::unix::fs::PermissionsExt; @@ -465,7 +466,7 @@ mod tests { Hop::Ssh(hop) }; let chain = TransportChain::new(vec![hop(), hop(), hop()]); - let output = crate::command::run( + let output = crate::client::command::run( &chain.command(&argv).unwrap(), None, std::time::Duration::from_secs(10), @@ -482,8 +483,10 @@ mod tests { assert!(tested > 0, "no supported shell found"); } + #[cfg(unix)] struct RemoveOnDrop(std::path::PathBuf); + #[cfg(unix)] impl Drop for RemoveOnDrop { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); diff --git a/rust/lithe-remote/src/lib.rs b/rust/lithe-remote/src/lib.rs new file mode 100644 index 0000000000..73bc79f224 --- /dev/null +++ b/rust/lithe-remote/src/lib.rs @@ -0,0 +1,14 @@ +//! Remote workspace protocol, client transports and host-side server. +//! +//! The wire types are always available. Desktop consumers enable `client`; +//! remote executable builds use `--no-default-features --features server` to +//! keep client transports out of the uploaded server. Host capabilities remain +//! independent of Core, language engines and the transport used to reach them. +//! +//! Note: .agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md + +#[cfg(feature = "client")] +pub mod client; +pub mod protocol; +#[cfg(feature = "server")] +pub mod server; diff --git a/rust/lithe-remote-protocol/src/frame.rs b/rust/lithe-remote/src/protocol/frame.rs similarity index 100% rename from rust/lithe-remote-protocol/src/frame.rs rename to rust/lithe-remote/src/protocol/frame.rs diff --git a/rust/lithe-remote-protocol/src/message.rs b/rust/lithe-remote/src/protocol/message.rs similarity index 100% rename from rust/lithe-remote-protocol/src/message.rs rename to rust/lithe-remote/src/protocol/message.rs diff --git a/rust/lithe-remote-protocol/src/methods.rs b/rust/lithe-remote/src/protocol/methods.rs similarity index 99% rename from rust/lithe-remote-protocol/src/methods.rs rename to rust/lithe-remote/src/protocol/methods.rs index 712a3474eb..c323d5c71f 100644 --- a/rust/lithe-remote-protocol/src/methods.rs +++ b/rust/lithe-remote/src/protocol/methods.rs @@ -120,7 +120,7 @@ pub const EVENT_TUNNEL_CLOSED: &str = "tunnel.closed"; /// Largest byte count one `fs.readFile` or `fs.writeFile` call may move. /// Base64 inflates it by a third, which keeps the frame under -/// [`crate::MAX_FRAME_BYTES`]. +/// [`crate::protocol::MAX_FRAME_BYTES`]. pub const MAX_TRANSFER_BYTES: u64 = 8 * 1024 * 1024; /// Encodes bytes for a JSON `data` field. diff --git a/rust/lithe-remote-protocol/src/lib.rs b/rust/lithe-remote/src/protocol/mod.rs similarity index 100% rename from rust/lithe-remote-protocol/src/lib.rs rename to rust/lithe-remote/src/protocol/mod.rs diff --git a/rust/lithe-remote-server/src/connection.rs b/rust/lithe-remote/src/server/connection.rs similarity index 96% rename from rust/lithe-remote-server/src/connection.rs rename to rust/lithe-remote/src/server/connection.rs index 1f9ce64443..5d4c9cc043 100644 --- a/rust/lithe-remote-server/src/connection.rs +++ b/rust/lithe-remote/src/server/connection.rs @@ -7,15 +7,15 @@ //! complete in any order; `process.kill` and `terminal.close` are among them //! because finding every member of a session scans the process table. -use crate::link::{Outbox, Outgoing}; -use crate::resources::{StartCompletion, StartGuard}; -use crate::session::Session; -use crate::{environment, fs, parse, platform, ports, to_value}; -use lithe_remote_protocol::frame::{read_frame, write_frame}; -use lithe_remote_protocol::methods::{ +use crate::protocol::frame::{read_frame, write_frame}; +use crate::protocol::methods::{ self, AckParams, HelloParams, HelloResult, ResumeParams, ResumeResult, SessionInfo, }; -use lithe_remote_protocol::{ErrorCode, Message, RemoteError, PREAMBLE, PROTOCOL_VERSION}; +use crate::protocol::{ErrorCode, Message, RemoteError, PREAMBLE, PROTOCOL_VERSION}; +use crate::server::link::{Outbox, Outgoing}; +use crate::server::resources::{StartCompletion, StartGuard}; +use crate::server::session::Session; +use crate::server::{environment, fs, parse, platform, ports, to_value}; use serde_json::{json, Value}; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -25,7 +25,7 @@ use std::thread; use std::time::Duration; /// Messages buffered for the writer thread. A full queue blocks producers; -/// see [`crate::link::Link::emit`]. +/// see [`crate::server::link::Link::emit`]. const OUTBOX_CAPACITY: usize = 256; /// How a connection ended. @@ -314,7 +314,7 @@ impl ResourceOrder { methods::FS_CREATE_RUN_DIRECTORY | methods::FS_RELEASE_RUN_DIRECTORY ) { let parsed: methods::RunDirectoryParams = parse(params.clone())?; - crate::run_directory::validate(&parsed)?; + crate::server::run_directory::validate(&parsed)?; return if method == methods::FS_CREATE_RUN_DIRECTORY { Ok(Self::Start { guard: session diff --git a/rust/lithe-remote-server/src/connection/tests.rs b/rust/lithe-remote/src/server/connection/tests.rs similarity index 99% rename from rust/lithe-remote-server/src/connection/tests.rs rename to rust/lithe-remote/src/server/connection/tests.rs index caa3f7a8c0..9500f9f202 100644 --- a/rust/lithe-remote-server/src/connection/tests.rs +++ b/rust/lithe-remote/src/server/connection/tests.rs @@ -127,7 +127,8 @@ fn cancellation_remains_owned_when_the_close_worker_is_rejected() { json!({"processId": 11, "force": true}), &Outbox(send), ); - let crate::link::Outgoing::Message(message) = receive.recv_timeout(STEP).unwrap() else { + let crate::server::link::Outgoing::Message(message) = receive.recv_timeout(STEP).unwrap() + else { panic!("expected reply"); }; assert_eq!( diff --git a/rust/lithe-remote-server/src/daemon.rs b/rust/lithe-remote/src/server/daemon.rs similarity index 97% rename from rust/lithe-remote-server/src/daemon.rs rename to rust/lithe-remote/src/server/daemon.rs index c448f42e91..161b370033 100644 --- a/rust/lithe-remote-server/src/daemon.rs +++ b/rust/lithe-remote/src/server/daemon.rs @@ -12,9 +12,9 @@ //! also verifies the peer's user id, so other users on a shared host cannot //! attach to a session. -use crate::connection::{self, Ended}; -use crate::platform; -use crate::session::Session; +use crate::server::connection::{self, Ended}; +use crate::server::platform; +use crate::server::session::Session; use sha2::{Digest, Sha256}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; @@ -94,7 +94,7 @@ impl PersistentOptions { }; // Preview builds can change protocol features without changing version. // Hash both identities to keep maximal session keys within socket limits. - let identity = crate::identity()?; + let identity = crate::server::identity()?; let name = daemon_name(&self.key, &identity.sha256); let run = state.join("run"); private_directory(&run)?; @@ -301,7 +301,7 @@ pub fn run_daemon(options: &PersistentOptions) -> io::Result<()> { next_connection += 1; let connection_id = next_connection; if let Some((_, previous)) = - crate::lock(¤t).replace((connection_id, stream.try_clone()?)) + crate::server::lock(¤t).replace((connection_id, stream.try_clone()?)) { let _ = previous.shutdown(std::net::Shutdown::Both); } @@ -328,7 +328,7 @@ pub fn run_daemon(options: &PersistentOptions) -> io::Result<()> { // linger on the host after every disconnect. // (`shutdown` is not enough: BSD rejects it with // ENOTCONN once the peer has half-closed.) - let mut current = crate::lock(¤t); + let mut current = crate::server::lock(¤t); if matches!(&*current, Some((id, _)) if *id == connection_id) { current.take(); } @@ -358,7 +358,7 @@ pub fn run_daemon(options: &PersistentOptions) -> io::Result<()> { let _ = fs::remove_file(&paths.socket); session.terminate_all(); - if let Some((_, stream)) = crate::lock(¤t).take() { + if let Some((_, stream)) = crate::server::lock(¤t).take() { let _ = stream.shutdown(std::net::Shutdown::Both); } drop(lock); diff --git a/rust/lithe-remote-server/src/environment.rs b/rust/lithe-remote/src/server/environment.rs similarity index 94% rename from rust/lithe-remote-server/src/environment.rs rename to rust/lithe-remote/src/server/environment.rs index d07742fbfe..a142f05db3 100644 --- a/rust/lithe-remote-server/src/environment.rs +++ b/rust/lithe-remote/src/server/environment.rs @@ -8,13 +8,13 @@ //! started as an interactive login shell, for the environment it ends up //! with, and caches the answer for the session. -use crate::platform::{self, ChildKind}; -use crate::session::Session; -use crate::{parse, to_value}; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ EnvironmentResolveParams, EnvironmentResolveResult, ResolvedEnvironmentSource, }; -use lithe_remote_protocol::RemoteError; +use crate::protocol::RemoteError; +use crate::server::platform::{self, ChildKind}; +use crate::server::session::Session; +use crate::server::{parse, to_value}; use serde_json::Value; use std::collections::BTreeMap; use std::io::{self, Write}; @@ -46,14 +46,14 @@ impl EnvironmentCache { session: &Arc, refresh: bool, ) -> EnvironmentResolveResult { - let _resolving = crate::lock(&self.resolving); + let _resolving = crate::server::lock(&self.resolving); if !refresh { - if let Some(cached) = crate::lock(&self.resolved).clone() { + if let Some(cached) = crate::server::lock(&self.resolved).clone() { return cached; } } let result = resolve_uncached(session); - *crate::lock(&self.resolved) = Some(result.clone()); + *crate::server::lock(&self.resolved) = Some(result.clone()); result } } diff --git a/rust/lithe-remote-server/src/fs.rs b/rust/lithe-remote/src/server/fs.rs similarity index 99% rename from rust/lithe-remote-server/src/fs.rs rename to rust/lithe-remote/src/server/fs.rs index d1228cd95d..c515ae2cac 100644 --- a/rust/lithe-remote-server/src/fs.rs +++ b/rust/lithe-remote/src/server/fs.rs @@ -4,14 +4,14 @@ //! shell the user could open over the same transport. It therefore does not //! confine paths to the workspace; the client decides which paths it shows. -use crate::{parse, platform, to_value}; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ self, decode_bytes, encode_bytes, CopyParams, CopyResult, CreateDirParams, DirectoryEntry, FileKind, FileMetadata, PathParams, ReadDirResult, ReadFileParams, ReadFileResult, RealPathResult, RemoveParams, RenameParams, SetPermissionsParams, VolumeInfo, WriteFileParams, MAX_TRANSFER_BYTES, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::{parse, platform, to_value}; use serde_json::{json, Value}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; diff --git a/rust/lithe-remote-server/src/link.rs b/rust/lithe-remote/src/server/link.rs similarity index 95% rename from rust/lithe-remote-server/src/link.rs rename to rust/lithe-remote/src/server/link.rs index a6994c4cf7..65038f183f 100644 --- a/rust/lithe-remote-server/src/link.rs +++ b/rust/lithe-remote/src/server/link.rs @@ -10,7 +10,7 @@ //! This lets a reconnecting client subscribe before its missed events arrive, //! and gives it each event exactly once. -use lithe_remote_protocol::Message; +use crate::protocol::Message; use serde::Serialize; use std::collections::VecDeque; use std::sync::mpsc::SyncSender; @@ -82,7 +82,7 @@ impl Link { return; } }; - let mut state = crate::lock(&self.state); + let mut state = crate::server::lock(&self.state); state.last_seq += 1; let seq = state.last_seq; let message = Message::Event { @@ -114,7 +114,7 @@ impl Link { } pub(crate) fn last_seq(&self) -> u64 { - crate::lock(&self.state).last_seq + crate::server::lock(&self.state).last_seq } /// Starts delivery to `outbox`. Under one lock it sends the response @@ -128,7 +128,7 @@ impl Link { after_seq: u64, respond: impl FnOnce(bool) -> Message, ) { - let mut state = crate::lock(&self.state); + let mut state = crate::server::lock(&self.state); let truncated = state.dropped_through > after_seq; outbox.send(respond(truncated)); for retained in state @@ -143,7 +143,7 @@ impl Link { /// Forgets events up to `seq`, which the client confirmed. pub(crate) fn acknowledge(&self, seq: u64) { - let mut state = crate::lock(&self.state); + let mut state = crate::server::lock(&self.state); while state .retained .front() @@ -158,7 +158,7 @@ impl Link { /// Detaches `connection` if it is still the attached one. A replacing /// connection may already have resumed, and must not be detached. pub(crate) fn detach(&self, connection: u64) { - let mut state = crate::lock(&self.state); + let mut state = crate::server::lock(&self.state); if matches!(&state.attached, Some((attached, _)) if *attached == connection) { state.attached = None; } diff --git a/rust/lithe-remote-server/src/lib.rs b/rust/lithe-remote/src/server/mod.rs similarity index 92% rename from rust/lithe-remote-server/src/lib.rs rename to rust/lithe-remote/src/server/mod.rs index 5f9d58254e..25bf11a4a5 100644 --- a/rust/lithe-remote-server/src/lib.rs +++ b/rust/lithe-remote/src/server/mod.rs @@ -4,9 +4,9 @@ //! filesystem access, walks, file watching, processes, terminals, the user's //! login environment and tunnels. It is started by a transport (SSH, //! `docker exec`, a chain of them, or a local child process in tests) and -//! speaks the `lithe-remote-protocol`. +//! speaks the shared wire protocol. //! -//! State lives in a [`session::Session`], not in a connection. In `--stdio` +//! State lives in a `session::Session`, not in a connection. In `--stdio` //! mode the session ends with its only connection. In `--connect` mode a //! daemon keeps the session alive across disconnections, so terminals and //! builds survive a dropped network link, and a reconnecting client receives @@ -41,8 +41,8 @@ mod watch; pub use daemon::{run_daemon, run_proxy, PersistentOptions}; pub use environment::print_environment; -use lithe_remote_protocol::methods::ServerIdentity; -use lithe_remote_protocol::{ErrorCode, RemoteError, PROTOCOL_VERSION}; +use crate::protocol::methods::ServerIdentity; +use crate::protocol::{ErrorCode, RemoteError, PROTOCOL_VERSION}; use serde::de::DeserializeOwned; use serde_json::Value; use sha2::{Digest, Sha256}; diff --git a/rust/lithe-remote-server/src/platform/linux.rs b/rust/lithe-remote/src/server/platform/linux.rs similarity index 98% rename from rust/lithe-remote-server/src/platform/linux.rs rename to rust/lithe-remote/src/server/platform/linux.rs index 8a6cc6feb0..4d675b3c42 100644 --- a/rust/lithe-remote-server/src/platform/linux.rs +++ b/rust/lithe-remote/src/server/platform/linux.rs @@ -1,7 +1,7 @@ //! Linux host adapter, used for SSH hosts and containers. use super::{unix, ChildKind}; -use lithe_remote_protocol::methods::{ListeningPort, OperatingSystem, OrphanProtection}; +use crate::protocol::methods::{ListeningPort, OperatingSystem, OrphanProtection}; use std::collections::HashSet; use std::io; use std::net::{Ipv4Addr, Ipv6Addr}; diff --git a/rust/lithe-remote-server/src/platform/linux_watch.rs b/rust/lithe-remote/src/server/platform/linux_watch.rs similarity index 99% rename from rust/lithe-remote-server/src/platform/linux_watch.rs rename to rust/lithe-remote/src/server/platform/linux_watch.rs index 96e468f8a5..28af5120b8 100644 --- a/rust/lithe-remote-server/src/platform/linux_watch.rs +++ b/rust/lithe-remote/src/server/platform/linux_watch.rs @@ -7,8 +7,8 @@ //! reported as created, because files written before the registration //! produce no events of their own. -use crate::watch::{Excludes, RawEvent, Sink}; -use lithe_remote_protocol::methods::{ChangeKind, WatchBackend}; +use crate::protocol::methods::{ChangeKind, WatchBackend}; +use crate::server::watch::{Excludes, RawEvent, Sink}; use std::collections::HashMap; use std::ffi::OsStr; use std::io; diff --git a/rust/lithe-remote-server/src/platform/macos.rs b/rust/lithe-remote/src/server/platform/macos.rs similarity index 98% rename from rust/lithe-remote-server/src/platform/macos.rs rename to rust/lithe-remote/src/server/platform/macos.rs index 55f82f68ee..a75d8c05a3 100644 --- a/rust/lithe-remote-server/src/platform/macos.rs +++ b/rust/lithe-remote/src/server/platform/macos.rs @@ -1,7 +1,7 @@ //! macOS host adapter, used for remote Macs reached over SSH. use super::{unix, ChildKind}; -use lithe_remote_protocol::methods::{ListeningPort, OperatingSystem, OrphanProtection}; +use crate::protocol::methods::{ListeningPort, OperatingSystem, OrphanProtection}; use std::collections::HashSet; use std::io; use std::os::fd::RawFd; diff --git a/rust/lithe-remote-server/src/platform/macos_watch.rs b/rust/lithe-remote/src/server/platform/macos_watch.rs similarity index 98% rename from rust/lithe-remote-server/src/platform/macos_watch.rs rename to rust/lithe-remote/src/server/platform/macos_watch.rs index 2be685176b..2bd8eb38af 100644 --- a/rust/lithe-remote-server/src/platform/macos_watch.rs +++ b/rust/lithe-remote/src/server/platform/macos_watch.rs @@ -7,8 +7,8 @@ //! alone. The bindings are declared here directly, which keeps the server //! free of framework wrapper crates. -use crate::watch::{Excludes, RawEvent, Sink}; -use lithe_remote_protocol::methods::{ChangeKind, WatchBackend}; +use crate::protocol::methods::{ChangeKind, WatchBackend}; +use crate::server::watch::{Excludes, RawEvent, Sink}; use std::collections::HashSet; use std::ffi::{c_char, c_void, CStr}; use std::io; @@ -274,7 +274,7 @@ impl CallbackState { if self.excludes.contains(&path) { return; } - let mut created = crate::lock(&self.created); + let mut created = crate::server::lock(&self.created); let kind = if std::fs::symlink_metadata(&path).is_err() { created.remove(&path); ChangeKind::Deleted diff --git a/rust/lithe-remote-server/src/platform/mod.rs b/rust/lithe-remote/src/server/platform/mod.rs similarity index 95% rename from rust/lithe-remote-server/src/platform/mod.rs rename to rust/lithe-remote/src/server/platform/mod.rs index 656831292e..4b67879c07 100644 --- a/rust/lithe-remote-server/src/platform/mod.rs +++ b/rust/lithe-remote/src/server/platform/mod.rs @@ -33,9 +33,7 @@ pub(crate) use unix::{ signal_process_group, signal_session, }; -use lithe_remote_protocol::methods::{ - Feature, HostCapabilities, ProcessCapabilities, WatchCapabilities, -}; +use crate::protocol::methods::{Feature, HostCapabilities, ProcessCapabilities, WatchCapabilities}; /// How a child is attached to its parent's session. #[derive(Clone, Copy, Debug, Eq, PartialEq)] diff --git a/rust/lithe-remote-server/src/platform/unix.rs b/rust/lithe-remote/src/server/platform/unix.rs similarity index 100% rename from rust/lithe-remote-server/src/platform/unix.rs rename to rust/lithe-remote/src/server/platform/unix.rs diff --git a/rust/lithe-remote/src/server/ports.rs b/rust/lithe-remote/src/server/ports.rs new file mode 100644 index 0000000000..e34f792e9d --- /dev/null +++ b/rust/lithe-remote/src/server/ports.rs @@ -0,0 +1,17 @@ +//! Listening ports on the remote, for automatic port forwarding. + +use crate::protocol::methods::ListeningPortsResult; +use crate::protocol::RemoteError; +use crate::server::platform; +use crate::server::session::Session; +use crate::server::to_value; +use serde_json::Value; +use std::sync::Arc; + +pub(crate) fn handle(_session: &Arc) -> Result { + let mut ports = + platform::listening_ports().map_err(|error| crate::server::fs::io_error(error, ""))?; + ports.sort(); + ports.dedup(); + to_value(ListeningPortsResult { ports }) +} diff --git a/rust/lithe-remote-server/src/process.rs b/rust/lithe-remote/src/server/process.rs similarity index 97% rename from rust/lithe-remote-server/src/process.rs rename to rust/lithe-remote/src/server/process.rs index f4f1beba67..fa5d047f0b 100644 --- a/rust/lithe-remote-server/src/process.rs +++ b/rust/lithe-remote/src/server/process.rs @@ -9,18 +9,18 @@ //! owning the session until it is empty, so `process.kill` and the final //! cleanup still reach them. -use crate::connection::Context; -use crate::fs::{host_path, io_error}; -use crate::platform::{self, ChildKind}; -use crate::resources::Registry; -use crate::session::Session; -use crate::{parse, to_value}; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ self, decode_bytes, encode_bytes, EnvironmentSource, KillParams, OutputStream, ProcessExitEvent, ProcessOutputEvent, ProcessParams, ProcessState, SpawnParams, SpawnResult, WriteStdinParams, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::fs::{host_path, io_error}; +use crate::server::platform::{self, ChildKind}; +use crate::server::resources::Registry; +use crate::server::session::Session; +use crate::server::{parse, to_value}; use serde_json::{json, Value}; use std::io::{self, Read, Write}; use std::os::unix::process::ExitStatusExt; @@ -447,7 +447,7 @@ impl OutputGate { // closed, which is its end-of-file. Err(_) => break, }; - let closed = crate::lock(&closed); + let closed = crate::server::lock(&closed); if !*closed { forward(&buffer[..read]); } @@ -464,7 +464,7 @@ impl OutputGate { break; } } - let mut closed = crate::lock(&self.closed); + let mut closed = crate::server::lock(&self.closed); *closed = true; exit(); } diff --git a/rust/lithe-remote-server/src/resources.rs b/rust/lithe-remote/src/server/resources.rs similarity index 89% rename from rust/lithe-remote-server/src/resources.rs rename to rust/lithe-remote/src/server/resources.rs index 5a714b6fdb..9a457bdf75 100644 --- a/rust/lithe-remote-server/src/resources.rs +++ b/rust/lithe-remote/src/server/resources.rs @@ -1,6 +1,6 @@ //! Shared bookkeeping for client-identified resources. -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; use serde_json::Value; use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -40,7 +40,7 @@ pub(crate) struct StartCompletion { impl StartCompletion { pub(crate) fn wait(&self, timeout: Duration) -> Result<(), RemoteError> { let deadline = Instant::now() + timeout; - let mut finished = crate::lock(&self.finished); + let mut finished = crate::server::lock(&self.finished); while !*finished { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -55,7 +55,7 @@ impl StartCompletion { .unwrap_or_else(|error| error.into_inner()) .0; } - match crate::lock(&self.cleanup_failure).clone() { + match crate::server::lock(&self.cleanup_failure).clone() { Some(error) => Err(error), None => Ok(()), } @@ -68,7 +68,7 @@ impl PendingStarts { family: &'static str, id: impl ToString, ) -> Result { - let mut entries = crate::lock(&self.entries); + let mut entries = crate::server::lock(&self.entries); let key = (family, id.to_string()); if entries.contains_key(&key) { return Err(RemoteError::new( @@ -91,9 +91,9 @@ impl PendingStarts { id: impl ToString, params: &Value, ) -> Option> { - let entries = crate::lock(&self.entries); + let entries = crate::server::lock(&self.entries); let completion = entries.get(&(family, id.to_string()))?; - let mut close = crate::lock(&completion.close_params); + let mut close = crate::server::lock(&completion.close_params); // Multiple cancellation attempts may strengthen, but never downgrade, // an explicit force kill while the child is still being created. if close @@ -117,21 +117,21 @@ pub(crate) struct StartGuard { impl StartGuard { /// Preserve creator-owned cleanup errors for close requests waiting on it. pub(crate) fn cleanup_failed(&self, error: RemoteError) { - *crate::lock(&self.completion.cleanup_failure) = Some(error); + *crate::server::lock(&self.completion.cleanup_failure) = Some(error); } /// After publication, atomically transfers close ownership either to the /// creating worker or to a subsequent ordinary close request. pub(crate) fn take_cancellation(&self) -> Option { - crate::lock(&self.starts.entries).remove(&self.key); - crate::lock(&self.completion.close_params).take() + crate::server::lock(&self.starts.entries).remove(&self.key); + crate::server::lock(&self.completion.close_params).take() } } impl Drop for StartGuard { fn drop(&mut self) { - let mut entries = crate::lock(&self.starts.entries); - *crate::lock(&self.completion.finished) = true; + let mut entries = crate::server::lock(&self.starts.entries); + *crate::server::lock(&self.completion.finished) = true; if entries .get(&self.key) .is_some_and(|entry| Arc::ptr_eq(entry, &self.completion)) @@ -160,7 +160,7 @@ impl Registry { } pub(crate) fn lock(&self) -> MutexGuard<'_, BTreeMap> { - crate::lock(&self.entries) + crate::server::lock(&self.entries) } /// Fails when the id is already live. The check and the later insert are @@ -235,7 +235,7 @@ pub(crate) struct InFlight { impl InFlight { /// Reserves a slot, or returns `false` when the limit is reached. pub(crate) fn try_acquire(&self) -> bool { - let mut count = crate::lock(&self.count); + let mut count = crate::server::lock(&self.count); if *count >= MAX_IN_FLIGHT_REQUESTS { return false; } @@ -244,7 +244,7 @@ impl InFlight { } pub(crate) fn release(&self) { - *crate::lock(&self.count) -= 1; + *crate::server::lock(&self.count) -= 1; self.changed.notify_all(); } @@ -252,7 +252,7 @@ impl InFlight { /// how many are still running. pub(crate) fn drain(&self, grace: Duration) -> usize { let deadline = Instant::now() + grace; - let mut count = crate::lock(&self.count); + let mut count = crate::server::lock(&self.count); while *count > 0 { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { diff --git a/rust/lithe-remote-server/src/run_directory.rs b/rust/lithe-remote/src/server/run_directory.rs similarity index 95% rename from rust/lithe-remote-server/src/run_directory.rs rename to rust/lithe-remote/src/server/run_directory.rs index f2a8962eb9..beb3453f85 100644 --- a/rust/lithe-remote-server/src/run_directory.rs +++ b/rust/lithe-remote/src/server/run_directory.rs @@ -2,11 +2,11 @@ //! Persistent disconnects retain them; explicit release or session termination //! removes them, even if the desktop vanished before receiving allocation. -use crate::connection::Context; -use crate::fs::io_error; -use crate::{lock, parse, platform}; -use lithe_remote_protocol::methods::{self, RunDirectoryParams}; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::methods::{self, RunDirectoryParams}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::fs::io_error; +use crate::server::{lock, parse, platform}; use serde_json::{json, Value}; use std::collections::BTreeMap; use std::fs; @@ -107,7 +107,7 @@ impl RunDirectories { .home .as_deref() .ok_or_else(|| RemoteError::new(ErrorCode::Unsupported, "host home unavailable"))?; - let home = crate::fs::host_path(home)?; + let home = crate::server::fs::host_path(home)?; let base = home.join(".lithe-server"); private_directory(&base)?; let parent = base.join("plugin-runs"); diff --git a/rust/lithe-remote-server/src/session.rs b/rust/lithe-remote/src/server/session.rs similarity index 84% rename from rust/lithe-remote-server/src/session.rs rename to rust/lithe-remote/src/server/session.rs index 06642380d4..9ecdd4c364 100644 --- a/rust/lithe-remote-server/src/session.rs +++ b/rust/lithe-remote/src/server/session.rs @@ -1,15 +1,15 @@ //! Session state that outlives individual connections. -use crate::environment::EnvironmentCache; -use crate::link::Link; -use crate::process::ProcessTable; -use crate::resources::{IdWatermark, InFlight, PendingStarts}; -use crate::spawner::Spawner; -use crate::terminal::TerminalTable; -use crate::tunnel::TunnelTable; -use crate::walk::WalkTable; -use crate::watch::WatchTable; -use lithe_remote_protocol::methods::ServerState; +use crate::protocol::methods::ServerState; +use crate::server::environment::EnvironmentCache; +use crate::server::link::Link; +use crate::server::process::ProcessTable; +use crate::server::resources::{IdWatermark, InFlight, PendingStarts}; +use crate::server::spawner::Spawner; +use crate::server::terminal::TerminalTable; +use crate::server::tunnel::TunnelTable; +use crate::server::walk::WalkTable; +use crate::server::watch::WatchTable; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -62,8 +62,8 @@ pub(crate) struct Session { pub(crate) watches: WatchTable, pub(crate) tunnels: TunnelTable, pub(crate) walks: WalkTable, - pub(crate) uploads: crate::upload::UploadTable, - pub(crate) run_directories: crate::run_directory::RunDirectories, + pub(crate) uploads: crate::server::upload::UploadTable, + pub(crate) run_directories: crate::server::run_directory::RunDirectories, /// Requests running on worker threads, drained before the session ends. pub(crate) workers: InFlight, pub(crate) starts: Arc, @@ -93,9 +93,9 @@ impl Session { watches: WatchTable::new(), tunnels: TunnelTable::new(), walks: WalkTable::new(), - uploads: crate::upload::UploadTable::default(), - run_directories: crate::run_directory::RunDirectories::new( - crate::platform::current_account().home_directory, + uploads: crate::server::upload::UploadTable::default(), + run_directories: crate::server::run_directory::RunDirectories::new( + crate::server::platform::current_account().home_directory, ), workers: InFlight::default(), starts: Arc::default(), @@ -110,13 +110,13 @@ impl Session { } pub(crate) fn set_shutdown_hook(&self, hook: impl FnOnce() + Send + 'static) { - *crate::lock(&self.shutdown_hook) = Some(Box::new(hook)); + *crate::server::lock(&self.shutdown_hook) = Some(Box::new(hook)); } /// Called for `server.shutdown` before it is answered. pub(crate) fn begin_shutdown(&self) { self.ending.store(true, Ordering::Release); - if let Some(hook) = crate::lock(&self.shutdown_hook).take() { + if let Some(hook) = crate::server::lock(&self.shutdown_hook).take() { hook(); } } @@ -136,11 +136,12 @@ impl Session { } pub(crate) fn set_idle_timeout(&self, seconds: u64) { - *crate::lock(&self.idle_timeout) = Duration::from_secs(seconds).min(MAX_IDLE_TIMEOUT); + *crate::server::lock(&self.idle_timeout) = + Duration::from_secs(seconds).min(MAX_IDLE_TIMEOUT); } pub(crate) fn idle_timeout(&self) -> Duration { - *crate::lock(&self.idle_timeout) + *crate::server::lock(&self.idle_timeout) } pub(crate) fn state(&self) -> ServerState { diff --git a/rust/lithe-remote-server/src/spawner.rs b/rust/lithe-remote/src/server/spawner.rs similarity index 97% rename from rust/lithe-remote-server/src/spawner.rs rename to rust/lithe-remote/src/server/spawner.rs index 2b0d2213e2..ad76ad975a 100644 --- a/rust/lithe-remote-server/src/spawner.rs +++ b/rust/lithe-remote/src/server/spawner.rs @@ -40,7 +40,7 @@ impl Spawner { let job: Job = Box::new(move || { let _ = reply.send(job()); }); - crate::lock(&self.jobs) + crate::server::lock(&self.jobs) .send(job) .expect("the spawner thread lives as long as the session"); result diff --git a/rust/lithe-remote-server/src/terminal.rs b/rust/lithe-remote/src/server/terminal.rs similarity index 92% rename from rust/lithe-remote-server/src/terminal.rs rename to rust/lithe-remote/src/server/terminal.rs index b670489ea4..bce07596dd 100644 --- a/rust/lithe-remote-server/src/terminal.rs +++ b/rust/lithe-remote/src/server/terminal.rs @@ -5,21 +5,21 @@ //! login would set it up. Job control, `Ctrl-C` and window-size changes //! therefore behave as users expect. -use crate::connection::Context; -use crate::fs::host_path; -use crate::platform::{self, ChildKind}; -use crate::process::{ - exit_parts, has_exited_leaders, prune, queue_input, spawn_error, start_input_writer, - terminate_groups, Leader, OutputGate, -}; -use crate::resources::Registry; -use crate::session::{Session, TERMINATION_GRACE}; -use crate::{parse, to_value}; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ self, decode_bytes, encode_bytes, TerminalExitEvent, TerminalOpenParams, TerminalOpenResult, TerminalOutputEvent, TerminalParams, TerminalResizeParams, TerminalState, TerminalWriteParams, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::fs::host_path; +use crate::server::platform::{self, ChildKind}; +use crate::server::process::{ + exit_parts, has_exited_leaders, prune, queue_input, spawn_error, start_input_writer, + terminate_groups, Leader, OutputGate, +}; +use crate::server::resources::Registry; +use crate::server::session::{Session, TERMINATION_GRACE}; +use crate::server::{parse, to_value}; use serde_json::Value; use std::fs::File; use std::os::fd::{AsRawFd, OwnedFd}; @@ -133,11 +133,11 @@ impl TerminalTable { .unwrap_or_else(|| "/".into()), }; let (master, slave) = platform::open_pty(params.columns, params.rows) - .map_err(|error| crate::fs::io_error(error, ""))?; + .map_err(|error| crate::server::fs::io_error(error, ""))?; let stdio = |fd: &OwnedFd| -> Result { fd.try_clone() .map(Stdio::from) - .map_err(|error| crate::fs::io_error(error, "")) + .map_err(|error| crate::server::fs::io_error(error, "")) }; command .current_dir(directory) @@ -160,12 +160,12 @@ impl TerminalTable { let reader = File::from( master .try_clone() - .map_err(|error| crate::fs::io_error(error, ""))?, + .map_err(|error| crate::server::fs::io_error(error, ""))?, ); let writer = File::from( master .try_clone() - .map_err(|error| crate::fs::io_error(error, ""))?, + .map_err(|error| crate::server::fs::io_error(error, ""))?, ); let pid = child.id(); let entry = RunningTerminal { @@ -242,7 +242,7 @@ impl TerminalTable { .ok_or_else(|| self.registry.unknown(params.terminal_id))?; // The kernel sends SIGWINCH to the foreground process group. platform::set_window_size(terminal.master.as_raw_fd(), params.columns, params.rows) - .map_err(|error| crate::fs::io_error(error, ""))?; + .map_err(|error| crate::server::fs::io_error(error, ""))?; terminal.columns = params.columns; terminal.rows = params.rows; Ok(()) @@ -281,7 +281,8 @@ impl TerminalTable { // Jobs that are not the server's children report no exit, so // the wait also wakes periodically to re-check the session. let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - terminals.wait_for_change(remaining.min(crate::process::SESSION_POLL_INTERVAL)); + terminals + .wait_for_change(remaining.min(crate::server::process::SESSION_POLL_INTERVAL)); } if owned() { let _ = platform::signal_session(pid, libc::SIGKILL); diff --git a/rust/lithe-remote-server/src/tunnel.rs b/rust/lithe-remote/src/server/tunnel.rs similarity index 95% rename from rust/lithe-remote-server/src/tunnel.rs rename to rust/lithe-remote/src/server/tunnel.rs index c8ec061727..a2ce589c0c 100644 --- a/rust/lithe-remote-server/src/tunnel.rs +++ b/rust/lithe-remote/src/server/tunnel.rs @@ -5,16 +5,16 @@ //! reachable from the user's machine. Tunnels travel inside the protocol //! stream, so the server never opens a listening port of its own. -use crate::connection::Context; -use crate::parse; -use crate::process::{queue_input, INPUT_QUEUE_CAPACITY}; -use crate::resources::Registry; -use crate::session::Session; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ self, decode_bytes, encode_bytes, TunnelClosedEvent, TunnelDataEvent, TunnelOpenParams, TunnelParams, TunnelTarget, TunnelWriteParams, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::parse; +use crate::server::process::{queue_input, INPUT_QUEUE_CAPACITY}; +use crate::server::resources::Registry; +use crate::server::session::Session; use serde_json::{json, Value}; use std::io::{self, Read, Write}; use std::net::{Shutdown, TcpStream, ToSocketAddrs}; @@ -168,7 +168,7 @@ impl TunnelTable { RemoteError::new(ErrorCode::ConnectFailed, format!("cannot connect: {error}")) .with_details(json!({ "osError": error.raw_os_error() })) })?; - let io_failure = |error: io::Error| crate::fs::io_error(error, ""); + let io_failure = |error: io::Error| crate::server::fs::io_error(error, ""); let reader = stream.try_clone().map_err(io_failure)?; let writer = stream.try_clone().map_err(io_failure)?; self.registry.insert( diff --git a/rust/lithe-remote-server/src/upload.rs b/rust/lithe-remote/src/server/upload.rs similarity index 97% rename from rust/lithe-remote-server/src/upload.rs rename to rust/lithe-remote/src/server/upload.rs index 770bf7aeb3..8e55b1bca0 100644 --- a/rust/lithe-remote-server/src/upload.rs +++ b/rust/lithe-remote/src/server/upload.rs @@ -1,11 +1,11 @@ //! Connection-owned, bounded uploads with verified atomic publication. //! Staging files disappear on abort, disconnect, expiry, or failed commit. -use crate::connection::Context; -use crate::fs::{host_path, io_error}; -use crate::{lock, parse, platform}; -use lithe_remote_protocol::methods::{self, UploadChunkParams, UploadParams, UploadStartParams}; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::methods::{self, UploadChunkParams, UploadParams, UploadStartParams}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::fs::{host_path, io_error}; +use crate::server::{lock, parse, platform}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; diff --git a/rust/lithe-remote-server/src/upload/tests.rs b/rust/lithe-remote/src/server/upload/tests.rs similarity index 97% rename from rust/lithe-remote-server/src/upload/tests.rs rename to rust/lithe-remote/src/server/upload/tests.rs index 53db81cc13..fe38235bb3 100644 --- a/rust/lithe-remote-server/src/upload/tests.rs +++ b/rust/lithe-remote/src/server/upload/tests.rs @@ -1,7 +1,7 @@ //! Upload isolation and cancellation without relying on slow disks or sleeps. use super::*; -use crate::session::Session; +use crate::server::session::Session; #[test] fn busy_file_does_not_block_other_uploads_or_connection_cleanup() { @@ -11,7 +11,7 @@ fn busy_file_does_not_block_other_uploads_or_connection_cleanup() { connection: 1, closed: Arc::default(), }; - let directory = crate::fs::handle( + let directory = crate::server::fs::handle( methods::FS_TEMP_DIRECTORY, json!({"prefix": "lithe-upload-lock-test"}), ) diff --git a/rust/lithe-remote-server/src/walk.rs b/rust/lithe-remote/src/server/walk.rs similarity index 94% rename from rust/lithe-remote-server/src/walk.rs rename to rust/lithe-remote/src/server/walk.rs index 828143b914..3d6a5781e3 100644 --- a/rust/lithe-remote-server/src/walk.rs +++ b/rust/lithe-remote/src/server/walk.rs @@ -4,15 +4,15 @@ //! a high-latency link is the difference between seconds and minutes for a //! large repository. Entries arrive in depth-first, name-sorted batches. -use crate::connection::Context; -use crate::fs::{host_path, kind_of}; -use crate::parse; -use crate::resources::Registry; -use crate::session::Session; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ self, FileKind, WalkDoneEvent, WalkEntriesEvent, WalkEntry, WalkParams, WalkStartParams, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::fs::{host_path, kind_of}; +use crate::server::parse; +use crate::server::resources::Registry; +use crate::server::session::Session; use serde_json::Value; use std::collections::BTreeSet; use std::path::{Path, PathBuf}; @@ -83,7 +83,7 @@ impl WalkTable { session.ids.observe(params.walk_id); let root = host_path(¶ms.root)?; if !std::fs::metadata(&root) - .map_err(|error| crate::fs::io_error(error, ¶ms.root))? + .map_err(|error| crate::server::fs::io_error(error, ¶ms.root))? .is_dir() { return Err(RemoteError::new( @@ -114,7 +114,7 @@ impl WalkTable { session.walks.registry.remove(done.walk_id); session.link.emit(methods::EVENT_WALK_DONE, done); }) - .map_err(|error| crate::fs::io_error(error, ""))?; + .map_err(|error| crate::server::fs::io_error(error, ""))?; Ok(()) } } diff --git a/rust/lithe-remote-server/src/watch.rs b/rust/lithe-remote/src/server/watch.rs similarity index 95% rename from rust/lithe-remote-server/src/watch.rs rename to rust/lithe-remote/src/server/watch.rs index 3d32056459..5cf05608b5 100644 --- a/rust/lithe-remote-server/src/watch.rs +++ b/rust/lithe-remote/src/server/watch.rs @@ -7,17 +7,17 @@ //! after its first change. When changes are lost, the batch carries //! `overflow` and the client rescans instead of trusting partial data. -use crate::connection::Context; -use crate::fs::host_path; -use crate::platform::watcher; -use crate::resources::Registry; -use crate::session::Session; -use crate::{parse, to_value}; -use lithe_remote_protocol::methods::{ +use crate::protocol::methods::{ self, ChangeKind, FileChange, WatchChangedEvent, WatchErrorCode, WatchErrorEvent, WatchParams, WatchStartParams, WatchStartResult, WatchState, }; -use lithe_remote_protocol::{ErrorCode, RemoteError}; +use crate::protocol::{ErrorCode, RemoteError}; +use crate::server::connection::Context; +use crate::server::fs::host_path; +use crate::server::platform::watcher; +use crate::server::resources::Registry; +use crate::server::session::Session; +use crate::server::{parse, to_value}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -144,7 +144,7 @@ impl WatchTable { self.registry.ensure_free(params.watch_id)?; let root = host_path(¶ms.root)?; if !std::fs::metadata(&root) - .map_err(|error| crate::fs::io_error(error, ¶ms.root))? + .map_err(|error| crate::server::fs::io_error(error, ¶ms.root))? .is_dir() { return Err(RemoteError::new( @@ -155,7 +155,7 @@ impl WatchTable { let excludes = Arc::new(Excludes::new(root.clone(), params.exclude_names)); let (sink, raw) = mpsc::channel(); let (backend, watched_directories) = watcher::start(root, excludes, sink) - .map_err(|error| crate::fs::io_error(error, ¶ms.root))?; + .map_err(|error| crate::server::fs::io_error(error, ¶ms.root))?; self.registry.insert( params.watch_id, RunningWatch { @@ -168,7 +168,7 @@ impl WatchTable { thread::Builder::new() .name("remote-watch".into()) .spawn(move || coalesce(raw, |batch| deliver(&session, watch_id, batch))) - .map_err(|error| crate::fs::io_error(error, ""))?; + .map_err(|error| crate::server::fs::io_error(error, ""))?; Ok(WatchStartResult { watched_directories, }) diff --git a/rust/lithe-remote-server/tests/persistence.rs b/rust/lithe-remote/tests/persistence.rs similarity index 97% rename from rust/lithe-remote-server/tests/persistence.rs rename to rust/lithe-remote/tests/persistence.rs index 3a93ec933b..eeebd03392 100644 --- a/rust/lithe-remote-server/tests/persistence.rs +++ b/rust/lithe-remote/tests/persistence.rs @@ -2,9 +2,9 @@ mod support; -use lithe_remote_client::api::{ProcessEvent, TerminalEvent}; -use lithe_remote_client::{SpawnSpec, TerminalSpec}; -use lithe_remote_protocol::methods::{self, OutputStream, ProcessExitEvent, ProcessOutputEvent}; +use lithe_remote::client::api::{ProcessEvent, TerminalEvent}; +use lithe_remote::client::{SpawnSpec, TerminalSpec}; +use lithe_remote::protocol::methods::{self, OutputStream, ProcessExitEvent, ProcessOutputEvent}; use std::collections::BTreeMap; use std::time::{Duration, Instant}; use support::{wait_until_gone, Daemon, TempDir, STEP_TIMEOUT}; @@ -252,8 +252,8 @@ fn idle_sessions_end_after_their_timeout() { fn concurrent_connections_share_one_daemon() { let daemon = Daemon::new("race"); let attempt = || { - lithe_remote_client::RemoteSession::connect( - &lithe_remote_client::TransportChain::local(), + lithe_remote::client::RemoteSession::connect( + &lithe_remote::client::TransportChain::local(), &daemon.command(), &support::options(), ) diff --git a/rust/lithe-remote-server/tests/processes.rs b/rust/lithe-remote/tests/processes.rs similarity index 95% rename from rust/lithe-remote-server/tests/processes.rs rename to rust/lithe-remote/tests/processes.rs index 6a2f323c1b..f0f0ff2270 100644 --- a/rust/lithe-remote-server/tests/processes.rs +++ b/rust/lithe-remote/tests/processes.rs @@ -2,13 +2,13 @@ mod support; -use lithe_remote_client::api::ProcessEvent; -use lithe_remote_client::session::ClientError; -use lithe_remote_client::SpawnSpec; -use lithe_remote_protocol::methods::{ +use lithe_remote::client::api::ProcessEvent; +use lithe_remote::client::session::ClientError; +use lithe_remote::client::SpawnSpec; +use lithe_remote::protocol::methods::{ self, EnvironmentSource, OutputStream, ResolvedEnvironmentSource, }; -use lithe_remote_protocol::ErrorCode; +use lithe_remote::protocol::ErrorCode; use serde_json::json; use std::collections::BTreeMap; use support::{connect, connect_with_environment, wait_until_gone, TempDir, STEP_TIMEOUT}; @@ -135,8 +135,8 @@ fn disconnecting_a_stdio_session_terminates_its_processes() { /// running, as a build tool that leaves a daemon behind does. Returns the /// handle and the surviving member's pid after the leader's exit event. fn spawn_session_that_outlives_its_leader( - session: &lithe_remote_client::RemoteSession, -) -> (lithe_remote_client::api::ProcessHandle<'_>, u32) { + session: &lithe_remote::client::RemoteSession, +) -> (lithe_remote::client::api::ProcessHandle<'_>, u32) { let process = session .spawn(SpawnSpec { argv: argv(&["sh", "-c", "sleep 60 >/dev/null 2>&1 & echo $!"]), diff --git a/rust/lithe-remote-server/tests/run_directories.rs b/rust/lithe-remote/tests/run_directories.rs similarity index 95% rename from rust/lithe-remote-server/tests/run_directories.rs rename to rust/lithe-remote/tests/run_directories.rs index 4d4c65de75..282b84766e 100644 --- a/rust/lithe-remote-server/tests/run_directories.rs +++ b/rust/lithe-remote/tests/run_directories.rs @@ -2,7 +2,7 @@ mod support; -use lithe_remote_protocol::{methods, ErrorCode}; +use lithe_remote::protocol::{methods, ErrorCode}; use serde_json::json; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::Path; @@ -35,7 +35,7 @@ fn run_directory_release_is_idempotent_and_never_adopts_existing_paths() { assert_eq!(std::fs::read(path.join("user")).unwrap(), b"not owned"); let invalid = request(methods::FS_CREATE_RUN_DIRECTORY, "../escape").unwrap_err(); assert!( - matches!(invalid, lithe_remote_client::session::ClientError::Remote(error) if error.code == ErrorCode::InvalidRequest) + matches!(invalid, lithe_remote::client::session::ClientError::Remote(error) if error.code == ErrorCode::InvalidRequest) ); session.shutdown(STEP_TIMEOUT); } @@ -129,7 +129,7 @@ fn allocation_rejects_shared_or_linked_parents_without_changing_them() { ) .unwrap_err(); assert!( - matches!(error, lithe_remote_client::session::ClientError::Remote(error) + matches!(error, lithe_remote::client::session::ClientError::Remote(error) if error.code == ErrorCode::PermissionDenied) ); assert_eq!(std::fs::metadata(&base).unwrap().mode() & 0o777, 0o755); @@ -199,7 +199,7 @@ fn persistent_disconnect_retains_snapshot_until_explicit_shutdown() { // The consuming process is still using this snapshot when the session // ends. Teardown must stop it before removing its working directory. let process = replacement - .spawn(lithe_remote_client::SpawnSpec { + .spawn(lithe_remote::client::SpawnSpec { argv: vec!["/bin/cat".into()], cwd: Some(path.to_string_lossy().into_owned()), pipe_stdin: true, @@ -225,8 +225,8 @@ fn abandoned_desktop_snapshot_is_reclaimed_after_persistent_session_expiry() { command.extend(daemon.command()); let mut options = support::options(); options.idle_timeout = Some(std::time::Duration::from_secs(1)); - let (session, _) = lithe_remote_client::RemoteSession::connect( - &lithe_remote_client::TransportChain::local(), + let (session, _) = lithe_remote::client::RemoteSession::connect( + &lithe_remote::client::TransportChain::local(), &command, &options, ) diff --git a/rust/lithe-remote-server/tests/server.rs b/rust/lithe-remote/tests/server.rs similarity index 98% rename from rust/lithe-remote-server/tests/server.rs rename to rust/lithe-remote/tests/server.rs index f81ad1f400..794e948a32 100644 --- a/rust/lithe-remote-server/tests/server.rs +++ b/rust/lithe-remote/tests/server.rs @@ -3,16 +3,16 @@ mod support; -use lithe_remote_client::bootstrap::Bootstrapper; -use lithe_remote_client::session::ClientError; -use lithe_remote_client::{ +use lithe_remote::client::bootstrap::Bootstrapper; +use lithe_remote::client::session::ClientError; +use lithe_remote::client::{ InstallLocation, RemoteSession, ServerArtifact, TransportChain, WriteOptions, }; -use lithe_remote_protocol::frame::{read_frame, skip_preamble, write_frame}; -use lithe_remote_protocol::methods::{ +use lithe_remote::protocol::frame::{read_frame, skip_preamble, write_frame}; +use lithe_remote::protocol::methods::{ self, encode_bytes, FileKind, FileVersion, OperatingSystem, OrphanProtection, WatchBackend, }; -use lithe_remote_protocol::{ErrorCode, Message}; +use lithe_remote::protocol::{ErrorCode, Message}; use serde_json::json; use sha2::{Digest, Sha256}; use std::io::BufReader; @@ -433,7 +433,7 @@ fn a_write_in_flight_at_disconnect_completes_without_leftovers() { send( 1, methods::SERVER_HELLO, - json!({ "protocolVersion": lithe_remote_protocol::PROTOCOL_VERSION, "clientName": "test" }), + json!({ "protocolVersion": lithe_remote::protocol::PROTOCOL_VERSION, "clientName": "test" }), ); send( 2, @@ -508,7 +508,7 @@ fn a_peer_that_stops_reading_cannot_block_requests_past_their_timeout() { gate: std::sync::Arc::clone(&gate), }; std::thread::spawn(move || { - let _ = served.send(lithe_remote_server::serve_stdio(reader, server_output)); + let _ = served.send(lithe_remote::server::serve_stdio(reader, server_output)); }); } let (session, _events) = @@ -831,7 +831,7 @@ fn bootstrap_uploads_once_and_reuses_the_verified_binary() { // asks for a password asks once; the second launch reuses the install. #[test] fn one_connection_installs_once_and_starts_the_server() { - use lithe_remote_client::launch::{launch, launch_command}; + use lithe_remote::client::launch::{launch, launch_command}; let install = TempDir::new("launch"); let root = install.path().to_str().unwrap().to_owned(); for expected_upload in [true, false] { @@ -852,7 +852,7 @@ fn one_connection_installs_once_and_starts_the_server() { }) .unwrap(); assert_eq!(launched.uploaded, expected_upload); - let options = lithe_remote_client::session::ConnectOptions { + let options = lithe_remote::client::session::ConnectOptions { client_name: "test".into(), handshake_timeout: STEP_TIMEOUT, ..Default::default() diff --git a/rust/lithe-remote-server/tests/support/mod.rs b/rust/lithe-remote/tests/support/mod.rs similarity index 97% rename from rust/lithe-remote-server/tests/support/mod.rs rename to rust/lithe-remote/tests/support/mod.rs index fc5b86231c..c140178921 100644 --- a/rust/lithe-remote-server/tests/support/mod.rs +++ b/rust/lithe-remote/tests/support/mod.rs @@ -3,8 +3,8 @@ #![allow(dead_code)] -use lithe_remote_client::session::{ConnectOptions, Event}; -use lithe_remote_client::{RemoteSession, TransportChain}; +use lithe_remote::client::session::{ConnectOptions, Event}; +use lithe_remote::client::{RemoteSession, TransportChain}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::Receiver; @@ -144,7 +144,7 @@ impl Daemon { /// then calls `resume`, as a reconnecting client does. pub fn connect_deferred(&self) -> (RemoteSession, Receiver) { let mut options = options(); - options.resume = lithe_remote_client::ResumeMode::Deferred; + options.resume = lithe_remote::client::ResumeMode::Deferred; self.connect_with(&options) } diff --git a/rust/lithe-remote-server/tests/terminals.rs b/rust/lithe-remote/tests/terminals.rs similarity index 96% rename from rust/lithe-remote-server/tests/terminals.rs rename to rust/lithe-remote/tests/terminals.rs index 9c161ae535..aeffe44ed7 100644 --- a/rust/lithe-remote-server/tests/terminals.rs +++ b/rust/lithe-remote/tests/terminals.rs @@ -2,8 +2,8 @@ mod support; -use lithe_remote_client::api::{TerminalEvent, TerminalHandle}; -use lithe_remote_client::TerminalSpec; +use lithe_remote::client::api::{TerminalEvent, TerminalHandle}; +use lithe_remote::client::TerminalSpec; use std::collections::BTreeMap; use std::time::Instant; use support::{connect, wait_until_gone, STEP_TIMEOUT}; @@ -44,7 +44,7 @@ fn read_until(terminal: &TerminalHandle<'_>, needle: &str) -> String { fn read_until_exit( terminal: &TerminalHandle<'_>, -) -> lithe_remote_protocol::methods::TerminalExitEvent { +) -> lithe_remote::protocol::methods::TerminalExitEvent { let deadline = Instant::now() + STEP_TIMEOUT; loop { match terminal.next_event(deadline.saturating_duration_since(Instant::now())) { diff --git a/rust/lithe-remote-server/tests/tunnels.rs b/rust/lithe-remote/tests/tunnels.rs similarity index 95% rename from rust/lithe-remote-server/tests/tunnels.rs rename to rust/lithe-remote/tests/tunnels.rs index af9a18c448..c63d3658db 100644 --- a/rust/lithe-remote-server/tests/tunnels.rs +++ b/rust/lithe-remote/tests/tunnels.rs @@ -2,9 +2,9 @@ mod support; -use lithe_remote_client::api::TunnelEvent; -use lithe_remote_client::PortForward; -use lithe_remote_protocol::methods::TunnelTarget; +use lithe_remote::client::api::TunnelEvent; +use lithe_remote::client::PortForward; +use lithe_remote::protocol::methods::TunnelTarget; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::os::unix::net::UnixListener; @@ -133,8 +133,8 @@ fn connecting_to_a_closed_port_fails_with_a_stable_code() { host: "127.0.0.1".into(), port, }) { - Err(lithe_remote_client::ClientError::Remote(error)) => { - assert_eq!(error.code, lithe_remote_protocol::ErrorCode::ConnectFailed) + Err(lithe_remote::client::ClientError::Remote(error)) => { + assert_eq!(error.code, lithe_remote::protocol::ErrorCode::ConnectFailed) } Err(other) => panic!("unexpected {other:?}"), Ok(_) => panic!("connected to a closed port"), @@ -172,7 +172,7 @@ fn port_forwards_bridge_local_connections_to_the_remote() { // the shared handle instead of waiting for sole ownership. session .request_value( - lithe_remote_protocol::methods::SERVER_SHUTDOWN, + lithe_remote::protocol::methods::SERVER_SHUTDOWN, serde_json::Value::Null, STEP_TIMEOUT, ) @@ -221,7 +221,7 @@ fn half_closed_requests_still_get_their_reply() { drop(forward); session .request_value( - lithe_remote_protocol::methods::SERVER_SHUTDOWN, + lithe_remote::protocol::methods::SERVER_SHUTDOWN, serde_json::Value::Null, STEP_TIMEOUT, ) diff --git a/rust/lithe-remote-server/tests/watch_and_walk.rs b/rust/lithe-remote/tests/watch_and_walk.rs similarity index 98% rename from rust/lithe-remote-server/tests/watch_and_walk.rs rename to rust/lithe-remote/tests/watch_and_walk.rs index db6cc8995b..b64a2de827 100644 --- a/rust/lithe-remote-server/tests/watch_and_walk.rs +++ b/rust/lithe-remote/tests/watch_and_walk.rs @@ -2,8 +2,8 @@ mod support; -use lithe_remote_client::api::{WatchEvent, WatchHandle}; -use lithe_remote_protocol::methods::{ChangeKind, FileKind, WatchErrorCode}; +use lithe_remote::client::api::{WatchEvent, WatchHandle}; +use lithe_remote::protocol::methods::{ChangeKind, FileKind, WatchErrorCode}; use std::collections::BTreeMap; use std::time::{Duration, Instant}; use support::{connect, TempDir, STEP_TIMEOUT}; diff --git a/scripts/build-remote-servers.sh b/scripts/build-remote-servers.sh index 3354329821..455d57d02b 100755 --- a/scripts/build-remote-servers.sh +++ b/scripts/build-remote-servers.sh @@ -32,12 +32,13 @@ for target in "${TARGETS[@]}"; do # The server is uploaded over slow links, so it is always an optimized build. env "$linker_variable=rust-lld" \ cargo build --locked --release --manifest-path "$ROOT_DIR/rust/Cargo.toml" \ - -p lithe-remote-server --target "$target" >&2 + -p lithe-remote --no-default-features --features server \ + --bin lithe-remote-server --target "$target" >&2 mkdir -p "$OUTPUT_DIR/$target" cp "$ROOT_DIR/rust/target/$target/release/lithe-remote-server" "$OUTPUT_DIR/$target/lithe-remote-server" done -version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$ROOT_DIR/rust/lithe-remote-server/Cargo.toml" | head -1)" +version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$ROOT_DIR/rust/lithe-remote/Cargo.toml" | head -1)" if [[ -z "$version" ]]; then print -u2 -- "build-remote-servers: could not read the server version" exit 1 diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index d03a2acc1e..951343fce8 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -122,6 +122,7 @@ classify_path() { swift=true plugins=true rust_core=true + rust_remote=true windows=true windows_rust=true git_validation=true @@ -265,11 +266,14 @@ classify_path() { windows=true windows_rust=true ;; - rust/lithe-remote-protocol/*|rust/lithe-remote-server/*|rust/lithe-remote-client/*) - # The remote workspace crates are not linked into a shipped product - # yet. Their Linux lane also exercises the server's Linux adapter, - # which macOS runners cannot. + rust/lithe-remote/*) + # Core links the client/protocol; the dedicated Linux lane also + # exercises host adapters that macOS runners cannot execute. + rust_core=true rust_remote=true + macos_release=true + windows=true + windows_rust=true ;; rust/*) # Unknown Rust workspace paths fail closed across all Rust-backed diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 077b1b6baf..c76afd947a 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -397,7 +397,8 @@ async function main() { // Includes mcp-registry-metadata: responses have no copy stage; featured.json // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. - // Includes remote-server-artifacts: packaging rebuilds them from this worktree's sources. + // Includes remote-server-artifacts: packaging rebuilds lithe-remote's + // server-only binary from this worktree; no validator or copy stage applies. // Includes remote-connection-state: SSH settings, keychain secrets and placeholder roots are user-owned. // Includes remote-workspace-session-state: host Git scratch and connection // discovery/index snapshots, copy staging, account Trash and watch subscriptions diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index 662c6ba473..5d87a76815 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -116,8 +116,8 @@ modify_rust_code() { printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 2 modify_rust_test() { printf '%s\n' '#[test]' 'fn value_is_two() { assert_eq!(2, 2); }' > rust/lithe-core/tests/value.rs; } modify_rust_source_test() { printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(2, 2); }' > rust/lithe-core/src/lsp/tests.rs; } modify_remote_rust() { - mkdir -p rust/lithe-remote-server/src - printf '%s\n' '//! Remote server.' > rust/lithe-remote-server/src/lib.rs + mkdir -p rust/lithe-remote/src + printf '%s\n' '//! Remote workspace.' > rust/lithe-remote/src/lib.rs } modify_remote_workflow() { mkdir -p .github/workflows @@ -219,9 +219,9 @@ assert_classification rust-test \ assert_classification rust-source-test \ "$(classification false false true false false false false false)" \ modify_rust_source_test -# Remote workspace crates select only their own Linux lane. +# Remote workspace changes validate both Core consumers and host adapters. assert_classification remote-rust \ - "$(classification false false false false false false false false false false pull_request true)" \ + "$(classification false false true true true true false false false false pull_request true)" \ modify_remote_rust assert_classification remote-workflow \ "$(classification false false false false false false false false false false pull_request true)" \ @@ -328,7 +328,7 @@ assert_classification worktree-resource-registry \ "$(classification true true true true true true false false)" \ modify_worktree_resource_registry assert_classification test-stability-runner \ - "$(classification true true true false true true false false true)" \ + "$(classification true true true false true true false false true false pull_request true)" \ modify_test_stability_runner modify_swift_version() { printf '%s\n' '6.3.3' > .swift-version; } modify_macos_toolchain_action() { @@ -359,6 +359,9 @@ LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-defers-runner-lanes LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-keeps-linux-lanes \ "$(classification false false false false false false true false)" \ modify_rust_comment +LITHE_CI_DRAFT_PULL_REQUEST=true assert_classification draft-remote-keeps-only-linux-lane \ + "$(classification false false false false false false false false false false pull_request true)" \ + modify_remote_rust # A rename classifies both endpoints instead of selecting every lane: the old # Rust source still selects Rust Core, packaging, and Windows Rust. modify_swift_and_rust() { diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 8ff9882620..e84f3c990a 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -77,6 +77,12 @@ try { assert.match(diagnostics(refused), new RegExp(`${id} is isolated.*cannot be reused`)); } const state = registry.excludedResources.find(item => item.id === "remote-connection-state"); + const servers = registry.excludedResources.find(item => item.id === "remote-server-artifacts"); + assert.match(servers.identity, /lithe-remote's lithe-remote-server/); + assert.match(servers.identity, /--no-default-features --features server/); + const buildServers = await fs.readFile(path.join(scriptDirectory, "build-remote-servers.sh"), "utf8"); + assert.match(buildServers, /-p lithe-remote --no-default-features --features server/); + assert.ok(buildServers.includes("rust/lithe-remote/Cargo.toml")); assert.ok(state.locations.some(location => location.includes("session-and-binary-hash"))); const snapshots = registry.excludedResources.find(item => item.id === "remote-workspace-session-state"); assert.match(snapshots.reason, /fs\.createRunDirectory/); diff --git a/scripts/verify-remote-workspace.sh b/scripts/verify-remote-workspace.sh index 25182e2c40..47629e1d3e 100755 --- a/scripts/verify-remote-workspace.sh +++ b/scripts/verify-remote-workspace.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Verifies the remote workspace crates: protocol, server and client. +# Verifies the remote workspace crate: protocol, client and server features. # # --static formatting, clippy, type checks of the server for every remote -# host target, and static Linux release builds held to a size +# host target and the Windows client, and static Linux builds held to a size # budget, because the server is uploaded to every remote # --tests unit and end-to-end tests with per-test timing reports # @@ -15,7 +15,7 @@ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" MANIFEST=rust/Cargo.toml -PACKAGES=(lithe-remote-protocol lithe-remote-server lithe-remote-client) +PACKAGE=lithe-remote SERVER_TARGETS=(x86_64-unknown-linux-musl aarch64-unknown-linux-musl aarch64-apple-darwin x86_64-apple-darwin) LINUX_TARGETS=(x86_64-unknown-linux-musl aarch64-unknown-linux-musl) @@ -34,15 +34,21 @@ case "${1:-}" in *) printf 'usage: %s [--static|--tests]\n' "$0" >&2; exit 2 ;; esac -package_arguments=() -for package in "${PACKAGES[@]}"; do - package_arguments+=(-p "$package") -done - if [[ "$run_static" == true ]]; then - cargo fmt --manifest-path "$MANIFEST" "${package_arguments[@]}" -- --check - cargo clippy --locked --manifest-path "$MANIFEST" "${package_arguments[@]}" --tests -- -D warnings + cargo fmt --manifest-path "$MANIFEST" -p "$PACKAGE" -- --check + for features in protocol client server client,server; do + feature_arguments=(--no-default-features) + if [[ "$features" != protocol ]]; then + feature_arguments+=(--features "$features") + fi + cargo clippy --locked --manifest-path "$MANIFEST" -p "$PACKAGE" \ + "${feature_arguments[@]}" --tests -- -D warnings + done installed_targets="$(rustup target list --installed 2>/dev/null || true)" + if ! grep -qx x86_64-pc-windows-msvc <<<"$installed_targets"; then + printf 'verify-remote-workspace: Rust target x86_64-pc-windows-msvc is missing; run: rustup target add x86_64-pc-windows-msvc\n' >&2 + exit 1 + fi for target in "${SERVER_TARGETS[@]}"; do if ! grep -qx "$target" <<<"$installed_targets"; then printf 'verify-remote-workspace: Rust target %s is missing; run: rustup target add %s\n' "$target" "$target" >&2 @@ -50,14 +56,20 @@ if [[ "$run_static" == true ]]; then fi # Type checking needs no cross linker, so every host target is checked # from any machine, including the adapter the tests cannot run here. - cargo check --locked --manifest-path "$MANIFEST" -p lithe-remote-server --target "$target" --tests + cargo check --locked --manifest-path "$MANIFEST" -p "$PACKAGE" \ + --no-default-features --features server --target "$target" --tests done + # The desktop client remains available on Windows even though server host + # adapters support only Linux/macOS. This needs no Windows linker. + cargo check --locked --manifest-path "$MANIFEST" -p "$PACKAGE" \ + --no-default-features --features client --target x86_64-pc-windows-msvc --tests # Rust's bundled lld links static musl binaries on Linux and macOS hosts # alike, so no cross toolchain, Docker image or zig is needed. for target in "${LINUX_TARGETS[@]}"; do linker_variable="CARGO_TARGET_$(tr '[:lower:]-' '[:upper:]_' <<<"$target")_LINKER" env "$linker_variable=rust-lld" \ - cargo build --locked --release --manifest-path "$MANIFEST" -p lithe-remote-server --target "$target" + cargo build --locked --release --manifest-path "$MANIFEST" -p "$PACKAGE" \ + --no-default-features --features server --bin lithe-remote-server --target "$target" binary="rust/target/$target/release/lithe-remote-server" size="$(wc -c <"$binary" | tr -d ' ')" printf 'lithe-remote-server %s: %s bytes (budget %s)\n' "$target" "$size" "$SERVER_SIZE_BUDGET_BYTES" @@ -69,12 +81,11 @@ if [[ "$run_static" == true ]]; then fi if [[ "$run_tests" == true ]]; then - for package in "${PACKAGES[@]}"; do - node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ - --manifest "$MANIFEST" --package "$package" \ - --suite-timeout-ms 180000 \ - --report ".artifacts/test-stability/${package#lithe-}-rust.json" - done + node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ + --manifest "$MANIFEST" --package "$PACKAGE" \ + --no-default-features --features client,server \ + --suite-timeout-ms 180000 \ + --report .artifacts/test-stability/remote-rust.json fi printf 'Remote workspace verification passed\n' diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index d7b077dc1d..00c64afb69 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -367,7 +367,7 @@ "rust/target/remote-servers/manifest.json", "/Contents/Resources/RemoteServers" ], - "identity": "Release builds of lithe-remote-server for the Linux musl targets, rebuilt from this worktree's sources by scripts/build-remote-servers.sh; the manifest carries only the crate version, not a toolchain or source stamp.", + "identity": "Release builds of lithe-remote's lithe-remote-server binary with --no-default-features --features server for the Linux musl targets, rebuilt from this worktree's sources by scripts/build-remote-servers.sh; the manifest carries only the crate version, not a toolchain or source stamp.", "reason": "Packaging rebuilds the servers on every run (about 25 seconds) inside this worktree's Cargo target directory, so a copy would only risk shipping binaries from other sources. The bundle copy is read-only: the app streams it to SSH hosts and never writes into RemoteServers, keeping code signing and Sparkle deltas intact." }, { diff --git a/shared/contracts/remote-workspace-protocol-v1.md b/shared/contracts/remote-workspace-protocol-v1.md index 01d2639bcb..f6783f8efa 100644 --- a/shared/contracts/remote-workspace-protocol-v1.md +++ b/shared/contracts/remote-workspace-protocol-v1.md @@ -2,7 +2,7 @@ The protocol between a Lithe client and `lithe-remote-server`, which runs at the end of a transport chain (SSH host, container, or a chain of both). The -Rust types in `rust/lithe-remote-protocol/` are the reference +Rust types in `rust/lithe-remote/src/protocol/` are the reference implementation; this document is the compatibility surface for other clients. Design rationale lives in `.agents/notes/proposed/architecture/2026-10-24-remote-development-workspace-host.md`. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index db9d3b919a..b899874aec 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -53,6 +53,30 @@ launch already succeeds there. Strings returned by the core are UTF-8 JSON allocated by Rust. The caller must release response strings with `lithe_core_free_string`. +### Remote workspace path identity + +Workspace-aware commands accept an optional top-level `remoteWorkspace` with +`sessionId`, `localRoot` (the native placeholder), and `remoteRoot` (the host +folder). Roots must be absolute without parent components; `localRoot` must +have a final component. Without this context commands retain local behavior. +Remote failures never trigger a local filesystem or process fallback. + +Paths below `remoteRoot` project below `localRoot`. Absolute host paths outside +that folder, including containing Git repositories and canonical symlink +targets, project below a virtual sibling named +`.lithe-remote-host`, followed by the host path with +its leading `/` removed. For example, opening `/srv/repo/src` as `/native/src` +returns `/native/src.lithe-remote-host/srv/repo` for the containing repository. +This namespace is not materialized or persisted as user data. The native +registry recognizes both namespaces as the **same binding** and attaches its +original context to subsequent requests (status, stage, watch, etc.). Core +reverses both projections before accessing the host. Never infer a connection +from a raw host absolute path or use `..` to escape the placeholder. Multiple +hosts with the same host path retain distinct native identities; mixed local +and remote roots or different bindings in one request remain unsupported. +Only schema-owned path/URI fields are mapped; opaque argv and user text stay +unchanged. This is a client/Core projection, not a remote-server wire change. + The ACP Agent calls use an opaque handle for one agent process and connection per workspace and agent; one connection carries many conversation sessions. `lithe_agent_open_json` accepts `{ "agentId"?: string, "command"?: string, diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index d0847325ea..5747bda85f 100644 --- a/shared/platform-feature-matrix/features/remote-development-workspace.json +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -16,10 +16,8 @@ "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemoteRuntimeLocator.swift", "rust/lithe-core/src/remote/sessions_integration_tests.rs", "rust/lithe-core/src/remote", - "rust/lithe-remote-client/src/launch.rs", - "rust/lithe-remote-protocol", - "rust/lithe-remote-server", - "rust/lithe-remote-client", + "rust/lithe-remote/src/client/launch.rs", + "rust/lithe-remote", "shared/contracts/remote-workspace-protocol-v1.md", ".agents/notes/implemented/feature/2026-10-24-remote-ssh-connections.md", ".agents/notes/implemented/architecture/2026-10-24-remote-workspace-routing.md", @@ -34,10 +32,10 @@ "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift", "macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift", - "rust/lithe-remote-server/src/connection/tests.rs", - "rust/lithe-remote-server/src/run_directory.rs", - "rust/lithe-remote-server/tests/run_directories.rs", - "rust/lithe-remote-server/src/upload/tests.rs" + "rust/lithe-remote/src/server/connection/tests.rs", + "rust/lithe-remote/src/server/run_directory.rs", + "rust/lithe-remote/tests/run_directories.rs", + "rust/lithe-remote/src/server/upload/tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -50,5 +48,5 @@ "verificationStatus": "not-applicable" }, "owner": "Workspace", - "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、远端服务强制终止后的快照安全回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。" + "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、远端服务强制终止后的快照安全回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。 远端子目录与符号链接根:验证发现父仓库后,仅用返回 URL 查询状态和暂存仍选择原连接;同名本机哨兵仓库的索引和文件保持不变,同一远端路径在不同连接上隔离;外部 Git 元数据监听的订阅与事件使用同一可逆身份。" } diff --git a/shared/platform-feature-matrix/features/remote-workspace.json b/shared/platform-feature-matrix/features/remote-workspace.json index 4850c39090..b46851c862 100644 --- a/shared/platform-feature-matrix/features/remote-workspace.json +++ b/shared/platform-feature-matrix/features/remote-workspace.json @@ -16,10 +16,10 @@ "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginPackageDeployer.swift", "macos/Sources/Lithe/Platform/MacOS/Remote/MacRemotePluginLaunchPreparer.swift", "macos/Tests/LitheTests/RemotePluginLaunchPreparerTests.swift", - "rust/lithe-remote-server/src/connection/tests.rs", - "rust/lithe-remote-server/src/run_directory.rs", - "rust/lithe-remote-server/tests/run_directories.rs", - "rust/lithe-remote-server/src/upload/tests.rs" + "rust/lithe-remote/src/server/connection/tests.rs", + "rust/lithe-remote/src/server/run_directory.rs", + "rust/lithe-remote/tests/run_directories.rs", + "rust/lithe-remote/src/server/upload/tests.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 2508fb28c0..ca3d4719f3 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -1177,7 +1177,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1417,7 +1417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2249,7 +2249,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry", ] [[package]] @@ -2264,7 +2264,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -2896,6 +2896,7 @@ dependencies = [ "lithe-agent-host", "lithe-git-host", "lithe-ide-host", + "lithe-remote", "quick-xml 0.37.5", "rand 0.8.7", "regex", @@ -2955,6 +2956,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "lithe-remote" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "lithe-terminal" version = "0.1.0" @@ -3158,7 +3169,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3666,7 +3677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -4170,7 +4181,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4606,7 +4617,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4663,7 +4674,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5202,7 +5213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5701,7 +5712,7 @@ dependencies = [ "thiserror 2.0.20", "tracing", "url", - "windows-registry 0.5.3", + "windows-registry", "windows-result 0.3.4", ] @@ -6004,7 +6015,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "toml 0.9.12+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6032,7 +6043,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6444,7 +6455,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6533,7 +6544,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7068,7 +7079,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7165,20 +7176,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -7266,18 +7264,7 @@ checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -7298,15 +7285,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -7316,15 +7294,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" From 531fcc7f5dd0f2ca8b6a817b6d2b9e20a8c8f1fe Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 29 Oct 2026 09:33:24 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(remote):=20=E4=BF=AE=E5=A4=8D=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=90=8E=E7=9A=84=20Agent=20Notes=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=8C=E8=A1=A5=E9=BD=90=E8=83=BD=E5=8A=9B=E7=9F=A9?= =?UTF-8?q?=E9=98=B5=E5=B9=B6=E6=94=B6=E7=B4=A7=E8=BF=9C=E7=A8=8B=E6=89=AB?= =?UTF-8?q?=E6=8F=8F=E5=A4=B1=E8=B4=A5=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两条 proposed 笔记的适用范围仍指向已合并的 rust/lithe-remote-client, 改为 rust/lithe-remote/src/client/,修复 Verify Agent Notes。 - 能力矩阵:remote-development-workspace 增加 BuildSystemService 与共享扫描证据, 写明运行配置发现尚未接入远程(Core 对 runConfig.inspect 返回 NotSupported, 仅 runConfig.selectJava 可用);run-discovery 增加同一说明。状态不变。 - 远程共享运行扫描只在链路取消、超时或中断时整体失败;主机对单个目录报告的 错误仍记为扫描不完整,与本机一致。判断集中到 workspace_io::is_transport_failure, detectors/scan.rs 与 workspace_scan.rs 共用,修正此前两处不一致。 - 新增集成测试:读取主机目录树且占位目录保持为空、单目录错误记为不完整、 根目录之下链路中断时扫描失败。 --- ...7-debug-network-paths-and-tcp-lifecycle.md | 4 +- ...026-10-27-remote-debug-adapter-over-ssh.md | 2 +- .../src/execution/detectors/scan.rs | 7 +- rust/lithe-core/src/execution/mod.rs | 2 +- .../src/execution/workspace_scan.rs | 15 +--- .../src/remote/sessions_integration_tests.rs | 84 +++++++++++++++++++ rust/lithe-core/src/remote/workspace_io.rs | 13 +++ .../remote-development-workspace.json | 6 +- .../features/run-discovery.json | 2 +- 9 files changed, 114 insertions(+), 21 deletions(-) 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 06b51b1bc3..f91daa3ad1 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 @@ -94,8 +94,8 @@ IDE -- DAP(调试适配器协议,stdio 或 TCP)--> 调试适配器 - `macos/Sources/Lithe/Platform/MacOS/Debug/` - `windows/tauri/src-tauri/src/debug.rs` - `shared/contracts/plugins/v1/` -- `rust/lithe-remote-client/src/transport.rs` -- `rust/lithe-remote-client/src/forward.rs` +- `rust/lithe-remote/src/client/transport.rs` +- `rust/lithe-remote/src/client/forward.rs` ## 依据 diff --git a/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md b/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md index 5b3904b3ff..3e81bd845c 100644 --- a/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md +++ b/.agents/notes/proposed/architecture/2026-10-27-remote-debug-adapter-over-ssh.md @@ -231,7 +231,7 @@ ## 适用范围 - `rust/lithe-core/src/debug/` -- `rust/lithe-remote-client/src/transport.rs` +- `rust/lithe-remote/src/client/transport.rs` - `shared/contracts/plugins/v1/` - `macos/Sources/Lithe/Platform/MacOS/Debug/` - `shared/i18n/` diff --git a/rust/lithe-core/src/execution/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs index ab003ea901..03974e145f 100644 --- a/rust/lithe-core/src/execution/detectors/scan.rs +++ b/rust/lithe-core/src/execution/detectors/scan.rs @@ -27,9 +27,10 @@ impl DirectoryContext { pub fn at(root: &Path, path: &Path) -> Result, CoreError> { let entries = match fs::read_dir(path) { Ok(entries) => entries, - Err(_) if !crate::remote::workspace::is_remote() => return Ok(None), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error.into()), + // Detection skips a directory the host cannot list, as it does locally, + // but a dropped link must not pass for a project without manifests. + Err(error) if fs::is_transport_failure(&error) => return Err(error.into()), + Err(_) => return Ok(None), }; let files = entries .flatten() diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index 6cd305a8bb..21eb46670c 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -22,7 +22,7 @@ pub use launch_command::{ WINDOWS_COMMAND_LINE_LIMIT, }; -mod workspace_scan; +pub(crate) mod workspace_scan; pub use workspace_scan::{is_run_scan_excluded_directory, list_run_java_sources}; mod environment; diff --git a/rust/lithe-core/src/execution/workspace_scan.rs b/rust/lithe-core/src/execution/workspace_scan.rs index 1aff1c9864..fc78d46a15 100644 --- a/rust/lithe-core/src/execution/workspace_scan.rs +++ b/rust/lithe-core/src/execution/workspace_scan.rs @@ -89,7 +89,8 @@ fn read_directory(path: &Path) -> io::Result> { .collect() } -fn scan_with_reader( +// Crate-visible so remote integration tests can drop the link between reads. +pub(crate) fn scan_with_reader( root: &Path, max_depth: Option, read: &impl Fn(&Path) -> io::Result>, @@ -129,17 +130,9 @@ fn visit_with_reader( Err(error) if path == root => return Err(CoreError::from(error)), // A broken remote transport says nothing about this directory, and // continuing would publish a model built from a truncated tree. - // Permission and missing-path failures stay explicit incomplete-scan + // Failures the host reports for one path stay explicit incomplete-scan // evidence, exactly as they do for a local workspace. - Err(error) - if crate::remote::workspace::is_remote() - && !matches!( - error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied - ) => - { - return Err(CoreError::from(error)); - } + Err(error) if fs::is_transport_failure(&error) => return Err(CoreError::from(error)), Err(_) => { unreadable_paths.insert(relative(root, &path)); continue; diff --git a/rust/lithe-core/src/remote/sessions_integration_tests.rs b/rust/lithe-core/src/remote/sessions_integration_tests.rs index 39d80fd579..146c4ebf49 100644 --- a/rust/lithe-core/src/remote/sessions_integration_tests.rs +++ b/rust/lithe-core/src/remote/sessions_integration_tests.rs @@ -641,6 +641,90 @@ fn remote_workspace_file_limits_preserve_host_content_and_report_disconnects() { assert_eq!(std::fs::read_dir(local).unwrap().count(), 0); } +// `runConfig.inspect` has no remote adapter yet; this pins the shared traversal's +// remote contract so enabling it cannot read the placeholder or accept a dead link. +#[test] +fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { + use crate::execution::workspace_scan::{scan_run_workspace, scan_with_reader}; + use crate::remote::{workspace, workspace_io as fs}; + + let host = HostFixture::new(); + let _deadline = crate::protocol::cancellation::Scope::begin(None, Some(10_000)); + let local = host.root.join("placeholder"); + let remote = host.root.join("host"); + std::fs::create_dir_all(remote.join("service/api")).unwrap(); + std::fs::write(remote.join("package.json"), "{}").unwrap(); + std::fs::write(remote.join("service/pom.xml"), "").unwrap(); + let workspace = serde_json::from_value( + json!({"sessionId": host.id, "localRoot": local, "remoteRoot": remote}), + ) + .unwrap(); + let _workspace = workspace::Scope::begin(Some(workspace), "build.detect").unwrap(); + + let scan = scan_run_workspace(&local, Some(3)).unwrap(); + let files: Vec<_> = scan + .directories + .iter() + .map(|directory| { + let relative = directory.path.strip_prefix(&local).unwrap(); + ( + relative.to_owned(), + directory.files.iter().cloned().collect::>(), + ) + }) + .collect(); + assert_eq!( + files, + [ + (std::path::PathBuf::new(), vec!["package.json".to_owned()]), + ("service".into(), vec!["pom.xml".to_owned()]), + ("service/api".into(), Vec::new()), + ] + ); + assert!(scan.unreadable_paths.is_empty()); + assert_eq!(std::fs::read_dir(&local).unwrap().count(), 0); + + // A directory the host reports as unreadable is incomplete-scan evidence, + // while the same session keeps serving its siblings. + let unreadable = scan_with_reader(&local, Some(3), &|path| { + if path.ends_with("service") { + return Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + } + fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok((entry.path(), kind.is_dir(), kind.is_symlink())) + }) + .collect() + }) + .unwrap(); + assert_eq!( + unreadable.unreadable_paths, + std::collections::BTreeSet::from(["service".to_owned()]) + ); + + // The link dropping after the root was listed must fail the whole scan, + // not publish the root alone as if the project had no modules. + let dropped = scan_with_reader(&local, Some(3), &|path| { + let entries = fs::read_dir(path)? + .map(|entry| { + let entry = entry?; + let kind = entry.file_type()?; + Ok((entry.path(), kind.is_dir(), kind.is_symlink())) + }) + .collect::>>(); + lookup(&host.id).unwrap().close(); + entries + }); + assert!( + dropped.is_err(), + "a dropped link must not look like a smaller project" + ); + assert!(scan_run_workspace(&local, Some(3)).is_err()); + assert_eq!(std::fs::read_dir(local).unwrap().count(), 0); +} + #[test] fn captured_host_process_preserves_binary_streams_and_cleans_up_limit_failures() { let host = HostFixture::new(); diff --git a/rust/lithe-core/src/remote/workspace_io.rs b/rust/lithe-core/src/remote/workspace_io.rs index 56b629a50e..abfa7ad778 100644 --- a/rust/lithe-core/src/remote/workspace_io.rs +++ b/rust/lithe-core/src/remote/workspace_io.rs @@ -41,6 +41,19 @@ fn rpc(host: &Host, method: &str, params: Value) -> io::Res serde_json::from_value(value).map_err(io::Error::other) } +/// Whether a remote workspace operation failed because the link was cancelled, +/// timed out or dropped, rather than describing one path. Keep in step with the +/// kinds assigned in `rpc`. Callers that tolerate unreadable paths must still +/// stop on these, or a dead connection looks like a smaller project. Always +/// false for local workspaces, where such kinds describe the path itself. +pub(crate) fn is_transport_failure(error: &io::Error) -> bool { + workspace::is_remote() + && matches!( + error.kind(), + io::ErrorKind::Interrupted | io::ErrorKind::TimedOut | io::ErrorKind::ConnectionAborted + ) +} + fn path(host: &Host, path: &Path) -> io::Result { host.remote_path(path) .to_str() diff --git a/shared/platform-feature-matrix/features/remote-development-workspace.json b/shared/platform-feature-matrix/features/remote-development-workspace.json index 5747bda85f..79556ff720 100644 --- a/shared/platform-feature-matrix/features/remote-development-workspace.json +++ b/shared/platform-feature-matrix/features/remote-development-workspace.json @@ -35,7 +35,9 @@ "rust/lithe-remote/src/server/connection/tests.rs", "rust/lithe-remote/src/server/run_directory.rs", "rust/lithe-remote/tests/run_directories.rs", - "rust/lithe-remote/src/server/upload/tests.rs" + "rust/lithe-remote/src/server/upload/tests.rs", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "rust/lithe-core/src/execution/workspace_scan.rs" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -48,5 +50,5 @@ "verificationStatus": "not-applicable" }, "owner": "Workspace", - "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、远端服务强制终止后的快照安全回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。 远端子目录与符号链接根:验证发现父仓库后,仅用返回 URL 查询状态和暂存仍选择原连接;同名本机哨兵仓库的索引和文件保持不变,同一远端路径在不同连接上隔离;外部 Git 元数据监听的订阅与事件使用同一可逆身份。" + "verification": "欢迎页点“远程开发”:新建 Linux SSH 连接,分别验证 SSH Agent、私钥、密码和 2FA,核对主机信息卡片;打开项目后验证文件树、编辑保存和外部修改冲突。在远程终端运行 pwd,检查真实主机目录;探测 Shell,确认仅出现远端可执行程序,连续缩放与取消启动无残留进程。刷新 JDK/Maven,确认读取远端环境且断线不回退本机。在远端仓库验证状态、暂存、提交、历史分页和 Git 设置来源;检查选择文件提交保留无关暂存,未保存缓冲区的行历史正确,worktree 创建、列举、锁定、删除及克隆均只操作远端目录;Shift+Shift 搜索远端文件与内容。Core 远端文件装入内存不超过 32 MiB,原子写入不超过 8 MiB,超限和断线均明确失败且不覆盖现有内容;更大的 Git 临时内容尚不支持。上述 I/O 路径已有适配和隔离 fixture,真实 SSH 完整流程待验收;构建 I/O、工具解析、状态目录、插件资源部署和通用 stdio 提供者接入已有实现;真实 SSH 上的完整构建和插件启动尚未验收。远程 Java 原生集成、SVN 兼容目标包和完整流程、Agent 执行位置选择、远端服务强制终止后的快照安全回收、远程 Mac 和容器入口仍待完成。运行 scripts/verify-remote-workspace.sh、RemoteRuntimeDiscoveryTests、RemoteTerminalTransportTests、RemoteShellDiscoveryTests 和 Core remote::sessions 集成测试。Linux musl 两种架构服务各保持不超过 1.5 MiB。远程开发窗口与 Open 面板在 800×650 父窗口中验证信息换行、架构标签、底部按钮和编辑期间尺寸稳定。 文件树新建、目录、复制、重命名与主机废纸篓已接入,14 项 Swift 路由/监听 fixture 通过;真实 SSH 验证外部创建、编辑、移动、删除、溢出重扫和 watcher 取消后无资源残留。 RemoteBuildExecutionTests、RemotePluginDeploymentTests 与 MacBuildProviderSessionsTests 覆盖字节/参数保留、信号和错误、上传完整性、原连接清理、异步准备及迟到启动;补充真实 SSH stdio 顺序、断线、背压与取消后再更新验证状态。 通用资源启动在读取请求时预留身份,启动期间的关闭由创建者保留最终清理责任;验证清理早于注册、工作线程饱和、失败启动、无效关闭参数,以及不同资源独立性;区分常驻会话断线保留、非驻留断线清理和会话关闭/空闲到期后的迟到资源清理。RemotePluginLaunchPreparerTests 使用真实声明覆盖目标架构、远端状态目录、缺失资源、准备中重连和取消清理;本机 fixture 不替代 SSH/Linux 验收。Java/JDT 等语言专属集成不在本轮通用能力续作范围。 会话拥有完整插件快照:验证分配回复丢失后按原身份释放、重复释放不接管已有目录、跨会话隔离、父目录权限与链接拒绝、删除失败(包括等待创建者清理)保留原身份供重试、普通断线清理、常驻断线保留及关闭/空闲到期回收;服务端强杀后的回收仍未实现。上传验证同文件并发 BUSY、不同文件独立提交,以及取消/到期不等待文件锁。Agent 后续单独处理,本轮不扩展。 远端子目录与符号链接根:验证发现父仓库后,仅用返回 URL 查询状态和暂存仍选择原连接;同名本机哨兵仓库的索引和文件保持不变,同一远端路径在不同连接上隔离;外部 Git 元数据监听的订阅与事件使用同一可逆身份。 运行配置发现与生成(runConfig.inspect 等)尚未接入远程工作区,Core 对它们返回 NotSupported,目前只有 runConfig.selectJava 可用,不得把本机扫描结果当作远端项目。共享运行扫描的远程契约由 Core 集成测试 shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops 覆盖:读取主机目录树且占位目录保持为空、主机报告的单目录错误记为扫描不完整、链路中断时整体失败而不发布截断结果。运行准备与测试发现复用远程构建的状态租约(BuildSystemService.withPlanContext),真实 SSH 上的验收仍待完成。" } diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index db07f98587..1bd9eb5273 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -49,5 +49,5 @@ }, "owner": "Run", "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向 primary,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。", - "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。" + "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。 远程工作区尚未接入运行配置发现:Core 对 runConfig.inspect 返回 NotSupported,仅 runConfig.selectJava 可用;共享扫描的远程契约与进度见 remote-development-workspace。" }