多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Warp SSH 补全的远程命令执行:基于 RemoteServer 长驻进程的 RunCommand 流水线设计

Warp SSH 补全的远程命令执行:基于 RemoteServer 长驻进程的 RunCommand 流水线设计 桌面应用开发者工具人工智能AI 应用AI Agent代码智能体【免费下载链接】warpWarp is an agentic development environment, born out of the terminal.项目地址https://gitcode.com/GitHub_Trending/wa/warp点击查看免费下载导读当用户在 Warp 中 SSH 登录远程主机后自动补全、语法高亮、自动建议autosuggestions等补全管线都需要在远程机器上执行 generator 命令如compgen -c、ls来收集候选结果。本文基于 Warp 开源仓库中的设计文档 specs/APP-3791/TECH.md 及其实现在仓库中的落地代码完整讲解「远程命令执行Remote Command Execution」方案通过持久化运行的remote_server进程与单条 SSH 连接以 length-delimited protobuf 协议复用同一连接执行任意 shell 命令替代旧的「每次命令开一条 SSH session」的RemoteCommandExecutor。读完本文你将掌握RunCommandRequest/RunCommandResponse协议字段设计、ServerModel的按会话 executor 分发、RemoteServerClient.run_command()的请求-响应关联机制以及RemoteServerCommandExecutor如何接入补全管线并实现并行命令执行。1. 问题背景SSH 补全管线的历史瓶颈Warp 的补全管线completions、syntax highlighting、autosuggestions通过CommandExecutortrait 抽象出「在某个会话上下文中执行命令」的能力见 command_executor.rs。对远程 SSH 会话旧实现是 remote_command_executor.rs 中的RemoteCommandExecutor它通过 SSH 的ControlMaster/ControlPath复用已建立的 SSH 连接但每次执行 generator 命令都会 fork 一个新的ssh子进程在新的 SSH channel 上执行命令由于 sshd 默认MaxSessions通常为 10当多个 generator 并发触发时很容易超限报出channel: open failed为规避该限制RemoteCommandExecutor::supports_parallel_command_execution()直接返回false源码中注释明确说明了这一原因把所有 generator 命令串行化执行拖慢补全速度此外每命令都要经历 SSH channel 建立/拆除的往返开销而一次典型补全周期会触发数十个 generator累计延迟明显。代码注释也印证了这一点/// The RemoteCommandExecutor cant be run in parallel because a remote box could have a value /// of MaxSessions ... Functionally, this would result in channel: open failed messages ... fn supports_parallel_command_execution(self) - bool { false }设计文档 specs/APP-3791/TECH.md 提出的目标就是把补全命令的执行路径改走已经存在的持久化remote_server进程该进程在远程主机上长期运行客户端通过单条 SSH 连接与它通信消息格式为[4 字节小端长度][protobuf bytes]从而在一条连接上多路复用所有命令。2. 整体架构单连接多路复用替代逐命令建会话整套方案分为五层均已在仓库中落地层次职责仓库位置Proto 协议定义RunCommandRequest/RunCommandResponse等消息crates/remote_server/proto/remote_server.proto服务端处理ServerModel将RunCommand分发给按会话注册的LocalCommandExecutorapp/src/remote_server/server_model.rs客户端 APIRemoteServerClient.run_command()发送请求并关联响应crates/remote_server/src/client/mod.rs执行器RemoteServerCommandExecutor一个走RemoteServerClient的CommandExecutorremote_server_executor.rs装配执行器通过事件订阅从RemoteServerManager获取/更新 clientcrates/remote_server/src/manager.rs连接建立与Initialize握手版本/主机协商由独立的RemoteServerManager.connect_session()流程负责不在本文 spec 范围内SessionBootstrapped通知携带session_id、shell_type、shell_path使服务端为每个会话创建匹配其启动 shell 的LocalCommandExecutor。错误条件只记录日志不向用户呈现。3. Proto 协议RunCommand 消息设计3.1 消息在信封中的位置在 remote_server.proto 中所有消息都包在顶层信封ClientMessage/ServerMessage里。RunCommandRequest作为session-scoped 请求挂在SessionScopedRequest的 oneof 的field 4而SessionScopedRequest本身是ClientMessage.messageoneof 的 field 3RunCommandResponse挂在ServerMessage.messageoneof 的field 10。与「host-scoped 请求」不同session-scoped 请求的响应始终由原连接返回不会做 fail-over。协议文件的头注释明确了线上编码格式[4-byte little-endian length][protobuf bytes]。3.2 RunCommandRequest 字段message RunCommandRequest { string command 1; // 要执行的 shell 命令 optional string working_directory 2; // 工作目录缺省用服务端默认值 mapstring, string environment_variables 3; // 以原生方式注入的 env uint64 session_id 4; // 路由到对应会话的 executor }设计要点environment_variables用map而非拼进命令串服务端通过cmd.envs(...)原生设置环境变量避免转义与注入风险。这与旧RemoteCommandExecutor恰恰相反——旧实现因为子进程跑在本地、只能把 env/cwd序列化成 shell 片段拼进命令字符串见 remote_command_executor.rs既繁琐又脆弱。session_id负责路由因为服务端为每个会话维护独立的 executor同一连接daemon 可服务多个连接靠session_id区分命令该由哪个 shell 执行。3.3 RunCommandResponse 字段message RunCommandSuccess { bytes stdout 1; bytes stderr 2; optional int32 exit_code 3; // 进程被信号杀死时Unix缺省 } message RunCommandResponse { oneof result { RunCommandSuccess success 1; RunCommandError error 2; } }要点stdout/stderr用bytes而非string避免对输出做 UTF-8 有效性假设补全命令可能输出任意字节exit_code为optional进程被信号杀死Unix时缺省错误用专用枚举RunCommandErrorCodeSESSION_NOT_FOUND、EXECUTION_FAILED区别于通用的ErrorResponseINVALID_REQUEST/INTERNAL。3.4 SessionBootstrapped会话-执行器注册通知message SessionBootstrapped { uint64 session_id 1; string shell_type 2; // 如 bash、zsh optional string shell_path 3; // 如 /usr/bin/zsh }它是 fire-and-forget 通知服务端不回复作用是在服务端按session_id创建对应的LocalCommandExecutor。带shell_path时服务端直接用该路径缺省则回退到裸 shell 名做 PATH 查找。4. 服务端实现ServerModel 的按会话执行器分发在 server_model.rs 中ServerModel维护一个HashMapSessionId, ArcLocalCommandExecutorexecutors任何会话必须先经SessionBootstrapped注册才能执行命令。4.1 SessionBootstrapped 处理L1849-L1881handle_session_bootstrapped的流程用ShellType::from_name(msg.shell_type)解析 shell 类型未知则safe_error!记录日志并提前返回通知无响应shell_path缺失时警告一次LocalCommandExecutor::new(shell_path, shell_type)内部会回退到裸 shell 名插入executors表若已存在同session_id的旧 executor重复 SessionBootstrappedlast-writer-wins 覆盖并打警告——在途命令持有各自的ArcLocalCommandExecutor会自然完成或失败。4.2 RunCommand 处理L1888-L1997handle_run_command的逻辑从请求取出command、cwd、env_vars空 map 归一为None按session_id查executors未注册则同步返回RunCommandError { code: SessionNotFound }——正常流程下每个会话都先走SessionBootstrapped因此这只应出现在通知丢失等异常场景通过self.spawn_request_handler(...)在后台执行executor.execute_local_command(command, cwd.as_deref(), env_vars, ExecuteCommandOptions::default())返回的 handle 存入in_progress使客户端后续可用Abort取消on_resolve回调主线程里做三件事输出截断stdout stderr超过remote_server::protocol::MAX_MESSAGE_SIZE - 1024预留 protobuf 帧头余量时按比例截断两路输出防止超线级消息上限包装RunCommandSuccess { stdout, stderr, exit_code }通过me.send_server_message(...)带原request_id发回RunCommandResponse执行失败则回RunCommandError { code: ExecutionFailed }。值得注意服务端调用的是LocalCommandExecutor::execute_local_command()而非 trait 方法execute_command()代码注释解释是因为 trait 方法需要Shell版本、选项、插件等来自 bootstrap 的信息而这里直接拿到的是 shell_type 路径。4.3 为什么复用 LocalCommandExecutor继承LocalCommandExecutor见 local_command_executor.rs带来三点关键能力shell 配置抑制旗标按 shell 类型注入shell_config_flag——bash 用--norc、zsh 用-f、fish 用--no-config、PowerShell 用-NoProfile避免 sourcing.bashrc/.zshrc引入用户交互逻辑与噪音这对 generator 命令是正确语义进程组追踪Unix 下以new_with_process_group启动子进程并登记ActiveProcessGroups取消时对整个进程组发 SIGKILLkill_all_processes_in_process_group对负 PID 发信号保证命令树被彻底清理kill_on_drop经commandcrate 包装子进程句柄被 drop 即杀死子进程——这正是服务端Abort机制能终止在途命令的底层保障。5. 客户端实现RemoteServerClient.run_command()客户端侧的核心在 crates/remote_server/src/client/mod.rs 的RemoteServerClientpub async fn run_command( self, session_id: SessionId, command: String, working_directory: OptionString, environment_variables: HashMapString, String, ) - ResultRunCommandResponse, ClientErrorrun_command()L808-L844使用与initialize()相同的请求-响应关联模式生成RequestId→ 构造ClientMessage::session_scoped(...)装入RunCommandRequest→ 调用send_request→ 匹配响应变体。收到非RunCommandResponse的响应则报UnexpectedResponse。底层的send_request_internalL872-L919机制值得展开创建 oneshot channel以request_id为键插入pending_requests一个并发 map通过outbound_tx把消息发给 writer 任务由它在 SSH stdin 上写出[4-byte LE length][protobuf bytes]以rx.with_timeout(REQUEST_TIMEOUT)等待关联响应REQUEST_TIMEOUT为 120 秒client/mod.rs超时清理pending_requests条目并向服务端发送Abortfire-and-forget 通知返回ClientError::Timeout连接已死disconnected标记被 reader 任务置位后任何新请求直接返回ClientError::Disconnected服务端返回的通用ErrorResponse会被转换为ClientError::ServerError { code, message }让调用方只需匹配成功变体。SessionBootstrapped通知由notify_session_bootstrapped()L416以 fire-and-forget 方式单独发送不期待响应。6. RemoteServerCommandExecutor接入补全管线的执行器执行器实现位于 remote_server_executor.rs其结构为pub struct RemoteServerCommandExecutor { session_id: SessionId, client: ArcRemoteServerClient, }设计演进说明spec specs/APP-3791/TECH.md 原始设计是RwLockOptionArcRemoteServerClient由主线程在连接建立/断开时写入Some/None因为当时执行器可能在 manager 完成握手前创建。落地代码将 executor 的创建收敛到「manager 已处于Connected且client_for_session返回Some」之后因此直接持有ArcRemoteServerClient断连情形则通过client.is_disconnected()短路判断处理见下。6.1 execute_command 的语义execute_command的核心逻辑断连短路client.is_disconnected()为真时直接返回Err避免发出注定失败的请求。源码注释特别强调刻意不合成Ok(empty)的CommandOutput因为补全/语法高亮管线会把Ok(empty)解释为「顶层命令为零个」而产生错误结果正常路径调用client.run_command(self.session_id, command, cwd, env_vars)超时与中止由send_request内部统一处理120sREQUEST_TIMEOUTAbort响应翻译Successexit_code Some(0)→CommandExitStatus::Success其余 →Failure组装CommandOutput { stdout, stderr, status, exit_code }exit_code经ExitCode::from转换Error若code SessionNotFound用safe_error!记录「SessionBootstrapped 通知可能丢失」的诊断日志随后返回Err空响应视为 proto 级 bug记录日志并返回Err。6.2 并行执行支持fn supports_parallel_command_execution(self) - bool { true }这是本方案相对旧实现的核心收益远程服务器把命令在单条 SSH 连接上多路复用天然不受MaxSessions限制RemoteCommandExecutor只能串行执行的约束被解除。7. 装配executor 如何从 RemoteServerManager 获取 client执行器不负责启动或管理服务端进程只从RemoteServerManager拿 client。管理器的核心状态机是每会话的RemoteSessionStateConnecting → Initializing → Connected → Disconnected事件分为会话级SessionConnected { session_id, host_id }、SessionDisconnected { session_id, host_id }与主机级HostConnected/HostDisconnected见 crates/remote_server/src/manager.rs。主机级host_to_sessions仅用于去重主机级模型如RepoMetadataModel不参与连接管理。Spec 设计的装配点在new_command_executor_for_local_tty_sessioncommand_executor.rs包含两条互补路径A. 创建时的 eager 检查——若会话已Connected立即把 client 克隆给 executorlet executor Arc::new(RemoteServerCommandExecutor::new(session_id)); let remote_server_manager RemoteServerManager::handle(ctx); let executor_clone executor.clone(); remote_server_manager.read(ctx, |manager, _ctx| { if let Some(client) manager.client_for_session(session_id) { executor_clone.set_client(Arc::clone(client)); } });B. 事件订阅跟踪连接生命周期——订阅RemoteServerManagerEvent按session_id过滤订阅会收到所有会话的事件let executor_clone executor.clone(); let remote_server_manager_clone remote_server_manager.clone(); ctx.subscribe_to_model(remote_server_manager, move |_sessions, event, ctx| { match event { RemoteServerManagerEvent::SessionConnected { session_id: sid, .. } if *sid session_id { remote_server_manager_clone.read(ctx, |manager, _ctx| { if let Some(client) manager.client_for_session(session_id) { executor_clone.set_client(Arc::clone(client)); } }); } RemoteServerManagerEvent::SessionDisconnected { session_id: sid, .. } if *sid session_id { executor_clone.clear_client(); } _ {} } });A 覆盖「executor 创建时服务端已就绪」的情况B 覆盖创建之后的连接/断开事件。断连时清空 client使execute_command返回干净的「未连接」结果重连时SessionConnected再次触发并写入新 client。落地代码则把装配进一步收敛只有FeatureFlag::SshRemoteServer开启且会话是 SSH wrapper 会话且manager 已有Connectedclient 时才构造RemoteServerCommandExecutor::new(session_id, client)否则记日志并回落到 ControlMaster 的RemoteCommandExecutor注释说明该回退行为由 specs/APP-3797 约定保障SshRemoteServer失败/被跳过的场景下补全仍可用。8. 分派顺序为什么 SshRemoteServer 分支排第一new_command_executor_for_local_tty_session的分派顺序spec 设计的理想顺序为1. SshRemoteServer IsLegacySSHSession::Yes → RemoteServerCommandExecutor [NEW] 2. SSHTmuxWrapper tmux_control_mode → TmuxCommandExecutor 3. SessionType::Local (various) → LocalCommandExecutor / MSYS2 / WSL 4. WarpifiedRemote legacy SSH !InBandForSSH → RemoteCommandExecutor 5. default → InBandCommandExecutor / NoOp把SshRemoteServer排在第一的理由是与其余三条 SSH 执行路径对比vsRemoteCommandExecutor分支 4后者每命令开新 SSH session、受MaxSessions限制且不能并行remote server 在单条持久连接上多路复用严格更优vsInBandCommandExecutor分支 5后者把命令注入用户可见的终端会话慢、脆弱、污染终端输出vsTmuxCommandExecutor分支 2后者用 tmux 包裹会话换取 generator 访问权remote server 提供同等能力且无 tmux 依赖。当 flag 开启时优先走持久化多路复用连接flag 关闭时保持原有分派逻辑不变。9. 端到端流程从补全 generator 到响应回填9.1 前置条件RemoteServerManager.connect_session()由独立流程触发已完成服务端启动 → 创建ArcRemoteServerClient状态Initializing→Initialize握手 →mark_session_connected状态ConnectedRemoteSessionState::Connected { client, host_id }。9.2 执行器创建SSH 会话完成 bootstrapSessions::initialize_bootstrapped_session()为该会话创建RemoteServerCommandExecutoreager 检查manager.client_for_session(session_id)若为Connected克隆ArcRemoteServerClient并set_client订阅RemoteServerManagerEvent按session_id过滤后续的SessionConnected/SessionDisconnected。9.3 RunCommand 完整链路以补全 generator 调用executor.execute_command(compgen -c, shell, cwd, env_vars, opts)为例RemoteServerCommandExecutor检查client.is_disconnected()通过后调用client.run_command(session_id, ...)run_command生成RequestId经send_request在pending_requests注册 oneshot把ClientMessage送入outbound_tx以 120sREQUEST_TIMEOUT等待超时则移除条目并发Abort客户端 writer 任务取消息编码为[4-byte LE length][protobuf bytes]写到 SSH stdin服务端 stdin reader 任务解码ClientMessage经ModelSpawner分发给ServerModel::handle_messagehandle_message匹配RunCommand经spawn_request_handler委托给LocalCommandExecutor::execute_local_command()句柄存入in_progress供Abort取消on_resolve主线程收到Output从in_progress移除、截断超限输出、构造RunCommandResponse { stdout, stderr, exit_code }经response_tx发回服务端 stdout writer 任务编码ServerMessage为[4-byte LE length][protobuf bytes]写到 SSH stdout客户端 reader 任务解码按request_id查pending_requestsresolve oneshotrun_command()收到RunCommandResponse返回给 executorexecutor 翻译exit_code Some(0)→Success否则Failure包装为CommandOutput { stdout, stderr, status, exit_code }交回补全管线。Abort的语义是 fire-and-forgethandle_message从in_progress移除句柄并调用handle.abort()后台 future 被丢弃kill_on_drop顺带杀死子进程on_abort回调记录取消日志不发响应。该spawn_abortablein_progress模式对NavigatedToDirectory等所有异步请求处理器通用。9.4 断连与重连断连SSH 断开或服务端崩溃 → 客户端 reader 任务 EOF → 清空pending_requests在途调用得ResponseChannelClosed→ manager 收到ClientEvent::Disconnected→mark_session_disconnected→ 发射SessionDisconnected→ executor 侧clear_client落地代码等价为is_disconnected()短路后续execute_command返回干净的失败结果并记日志重连manager 重建RemoteServerClient并发射SessionConnected→ 订阅回调克隆新 client →set_client补全自动恢复。10. 总结该方案带来的能力边界从设计文档与落地代码可以得出本方案的核心收益与边界收益一解除MaxSessions约束。所有 generator 命令复用一条 SSH 连接supports_parallel_command_execution()返回true补全延迟不再被串行化放大收益二降低每命令开销。省去逐命令的 SSH channel 建立/拆除几十个 generator 的典型补全周期受益明显收益三执行语义更正确。环境变量原生注入不拼字符串、cwd 由服务端设置、shell 配置抑制旗标保证 generator 不加载用户 rc 文件、输出以bytes传输不做编码假设边界执行器依赖 manager 已处于Connected状态服务端未注册会话、输出超限截断、断连期间的请求均以日志或干净的失败结果呈现不打扰用户。SSH wrapper 会话在 flag 关闭或 remote-server 设置失败时仍会回落到基于 ControlMaster 的旧实现保证功能可用性。如需深入可直接阅读Proto 协议、服务端 ServerModel、客户端 RemoteServerClient、执行器实现 与 CommandExecutor 分派。赞分享桌面应用开发者工具人工智能AI 应用AI Agent代码智能体【免费下载链接】warpWarp is an agentic development environment, born out of the terminal.项目地址https://gitcode.com/GitHub_Trending/wa/warp点击查看免费下载相关推荐Warp 远程会话中的 ApplyFileDiffs基于 RemoteServer RPC 的远程 Diff 应用架构设计Warp 远程会话中的 ApplyFileDiffs基于 RemoteServer RPC 的远程 Diff 应用架构设计 本技术指南深入讲解 Warpag桌面应用开发者工具人工智能AI 应用AI Agent代码智能体Kedro ThreadRunner 详解基于线程的流水线并行执行方案Kedro ThreadRunner 详解基于线程的流水线并行执行方案 导读 ThreadRunner 是 Kedro 框架中基于 Python 线程thr数据工程工作流自动化GitHub Actions 实现远程 SSH 命令执行的解决方案GitHub Actions 实现远程 SSH 命令执行的解决方案 1. 项目基础介绍 ssh action 是一个开源项目它基于 GitHub ActionCI/CD创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表