Skip to main content

zerolaunch_plugin_host/
process.rs

1//! PluginProcess — subprocess lifecycle management.
2
3use parking_lot::RwLock;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7use tokio::sync::mpsc;
8use tracing::{debug, error, info, warn};
9
10use zerolaunch_plugin_api::config::{ConfigActionDef, SettingDefinition};
11use zerolaunch_plugin_api::{PanelInteraction, ResultAction};
12use zerolaunch_plugin_protocol::manifest::Manifest;
13use zerolaunch_plugin_protocol::messages::*;
14use zerolaunch_plugin_protocol::methods::plugin as plugin_methods;
15use zerolaunch_plugin_protocol::{ProtocolError, PROTOCOL_VERSION};
16
17use crate::client::{IncomingRequest, JsonRpcClient};
18use crate::host_dispatch::{HostCallHandler, PluginEventSink};
19use crate::transport::stdio::StdioTransport;
20
21/// Tracks the lifecycle state of a plugin subprocess.
22#[derive(Debug, Clone)]
23pub enum ProcessState {
24    Starting,
25    Running,
26    Crashed { restarts: u32, last_error: String },
27    Stopped,
28    Error(String),
29}
30
31/// Result of the initialization handshake with a plugin subprocess.
32pub struct InitResult {
33    pub plugin_id: String,
34    pub components: Vec<ComponentDescriptor>,
35    pub settings_schemas: Vec<(String, Vec<SettingDefinition>)>,
36    pub settings_values: Vec<(String, serde_json::Value)>,
37    pub config_actions_map: Vec<(String, Vec<ConfigActionDef>)>,
38    /// 每个 ActionExecutor 组件的 supported_actions 结果,按 component_id 索引。
39    /// 在 discover_components 期间通过 plugin/supported_actions 获取,
40    /// 供 build_adapters 直接使用,避免 ConfigActionDef → ResultAction 语义错配。
41    pub executor_actions_map: Vec<(String, Vec<ResultAction>)>,
42    /// 每个组件的默认启用状态(plugin/get_default_enabled),按 component_id 索引。
43    pub default_enabled_map: Vec<(String, bool)>,
44    /// Plugin 组件的交互策略(plugin/interaction_policy)初始值,按 component_id 索引。
45    /// 策略为插件级语义(仅 Plugin 组件有),只对 Plugin 种类拉取;
46    /// 查询/设置变更期间由 RemoteComponent 刷新(策略可随设置动态变化)。
47    pub interaction_policy_map: Vec<(String, PanelInteraction)>,
48    /// KeywordOptimizer 组件的声明属性(plugin/keyword_optimizer_info),按
49    /// component_id 索引。input_source / priority 只对 KeywordOptimizer 种类拉取;
50    /// 设置变更期间由 RemoteComponent 刷新。
51    pub keyword_optimizer_info_map: Vec<(String, KeywordOptimizerInfo)>,
52}
53
54/// Manages a single plugin subprocess instance.
55pub struct PluginProcess {
56    pub plugin_id: String,
57    pub manifest: Manifest,
58    pub state: Arc<RwLock<ProcessState>>,
59    pub client: Arc<JsonRpcClient>,
60    pub data_dir: PathBuf,
61    /// Handle to the child process for health monitoring.
62    child_handle: Arc<parking_lot::Mutex<Option<tokio::process::Child>>>,
63    /// Channel to notify PluginHostManager when the process crashes and needs restart.
64    crash_tx: mpsc::Sender<String>,
65    /// 该插件已重启的次数(0 = 初次启动)。
66    /// 仅用于观测;权威的重启计数器在 PluginRestartContext 中。
67    pub restart_count: u32,
68    /// 子进程的 PID,用于强制终止(当优雅关闭超时时兜底)。
69    pub pid: Option<u32>,
70    /// watchdog 任务句柄:进程退出时该任务完成,shutdown 据此等待退出。
71    watchdog_handle: Option<tokio::task::JoinHandle<()>>,
72}
73
74impl PluginProcess {
75    /// Spawn the plugin subprocess, complete the initialize handshake,
76    /// and start stderr log collection.
77    ///
78    /// `crash_tx` is a channel sender owned by the `PluginHostManager`.
79    /// When the watchdog detects a crash, it sends the `plugin_id` on this
80    /// channel so the manager can trigger a re-spawn.
81    /// 启动插件子进程并完成握手与协议版本兼容性校验。
82    /// 参数较多(进程/目录/回调/计数/locale),按先例 allow too_many_arguments。
83    #[allow(clippy::too_many_arguments)]
84    pub async fn spawn(
85        manifest: &Manifest,
86        plugin_dir: &Path,
87        data_dir: &Path,
88        log_dir: &Path,
89        host_call_handler: Arc<dyn HostCallHandler>,
90        crash_tx: mpsc::Sender<String>,
91        // 该插件已重启的次数(0 = 初次加载)。
92        restart_count: u32,
93        // 宿主当前界面语言(如 "zh-Hans"),随 initialize 握手下发。
94        locale: &str,
95    ) -> Result<Self, ProtocolError> {
96        let plugin_id = manifest.plugin.id.clone();
97
98        // Resolve the command path
99        let cmd_path = plugin_dir.join(&manifest.runtime.command);
100        if !cmd_path.exists() {
101            return Err(ProtocolError::InvalidFrame(format!(
102                "plugin command not found: {}",
103                cmd_path.display()
104            )));
105        }
106
107        // Build environment: pass data_dir and log_dir
108        let env = vec![
109            ("ZEROLAUNCH_PLUGIN_ID".to_string(), plugin_id.clone()),
110            (
111                "ZEROLAUNCH_DATA_DIR".to_string(),
112                data_dir.to_string_lossy().to_string(),
113            ),
114            (
115                "ZEROLAUNCH_LOG_DIR".to_string(),
116                log_dir.to_string_lossy().to_string(),
117            ),
118        ];
119
120        info!("Spawning plugin {}: {:?}", plugin_id, cmd_path);
121
122        let transport =
123            StdioTransport::spawn(&cmd_path, &manifest.runtime.args, plugin_dir, &env).await?;
124
125        let pid = transport.pid();
126        // Split transport so we can keep the child handle for health monitoring
127        let StdioTransport {
128            child,
129            stdin: child_stdin,
130            stdout: child_stdout,
131            stderr: child_stderr,
132        } = transport;
133        let child_handle = Arc::new(parking_lot::Mutex::new(Some(child)));
134
135        // Start stderr logger task
136        let log_file = log_dir.join(format!("{}.log", plugin_id));
137        let stderr_pid = plugin_id.clone();
138        let mut stderr_reader = child_stderr;
139        tokio::spawn(async move {
140            let mut buf = [0u8; 4096];
141            loop {
142                match tokio::io::AsyncReadExt::read(&mut stderr_reader, &mut buf).await {
143                    Ok(0) => break,
144                    Ok(n) => {
145                        let text = String::from_utf8_lossy(&buf[..n]);
146                        let _ = append_to_log(&log_file, &text).await;
147                    }
148                    Err(e) => {
149                        debug!("stderr read error for {}: {}", stderr_pid, e);
150                        break;
151                    }
152                }
153            }
154        });
155
156        // Set up incoming request/notification channels
157        let (incoming_request_tx, mut incoming_request_rx) = mpsc::channel::<IncomingRequest>(64);
158        let (incoming_notification_tx, _incoming_notification_rx) =
159            mpsc::channel::<(String, serde_json::Value)>(64);
160
161        let client = JsonRpcClient::new(
162            child_stdout,
163            child_stdin,
164            incoming_request_tx,
165            incoming_notification_tx,
166        );
167
168        // 注入宿主 → 插件事件推送通道(重启 spawn 覆盖为新通道)。
169        host_call_handler.attach_event_sink(spawn_event_forwarder(client.clone()));
170
171        // Spawn task to handle incoming requests from the plugin (host/* calls)
172        let hc = host_call_handler.clone();
173        let cl = client.clone();
174        tokio::spawn(async move {
175            while let Some(incoming) = incoming_request_rx.recv().await {
176                debug!(
177                    "处理插件请求: method={} params={}",
178                    incoming.method,
179                    zerolaunch_plugin_protocol::codec::summarize_value(&incoming.params)
180                );
181                let result = hc.handle_host_call(&incoming.method, incoming.params).await;
182                match result {
183                    Ok(val) => {
184                        if let Err(e) = cl.respond_ok(incoming.id, val).await {
185                            error!("Failed to send response for {}: {}", incoming.method, e);
186                        }
187                    }
188                    Err(err) => {
189                        if let Err(e) = cl.respond_err(incoming.id, err).await {
190                            error!(
191                                "Failed to send error response for {}: {}",
192                                incoming.method, e
193                            );
194                        }
195                    }
196                }
197            }
198        });
199
200        let host_version = env!("CARGO_PKG_VERSION").to_string();
201        let protocol_version = zerolaunch_plugin_protocol::PROTOCOL_VERSION.to_string();
202
203        // Step 1: plugin/initialize
204        let init_params = InitializeParams {
205            host_version: host_version.clone(),
206            protocol_version: protocol_version.clone(),
207            data_dir: data_dir.to_string_lossy().to_string(),
208            log_dir: log_dir.to_string_lossy().to_string(),
209            plugin_id: plugin_id.clone(),
210            locale: locale.to_string(),
211        };
212
213        let init_result: InitializeResult = client
214            .call(
215                plugin_methods::INITIALIZE,
216                init_params,
217                Duration::from_secs(manifest.runtime.startup_timeout),
218            )
219            .await?;
220
221        info!(
222            "Plugin {} initialized (pid={:?}), result: {:?}",
223            plugin_id, pid, init_result
224        );
225
226        // 协议版本兼容性闸门:major 相同即兼容(宿主驱动模型下 minor 差异双向兼容——
227        // 宿主只调用自己知道的方法,新方法对旧插件可选且宿主容错 METHOD_NOT_FOUND)。
228        // major 不同 = 破坏性载荷变更,拒绝比半工作状态可诊断。
229        if !protocol_version_compatible(&init_result.protocol_version, PROTOCOL_VERSION) {
230            return Err(ProtocolError::ProtocolVersionIncompatible {
231                plugin: plugin_id.clone(),
232                expected: PROTOCOL_VERSION.to_string(),
233                got: init_result.protocol_version,
234            });
235        }
236
237        let mut process = Self {
238            plugin_id: plugin_id.clone(),
239            manifest: manifest.clone(),
240            state: Arc::new(RwLock::new(ProcessState::Running)),
241            client,
242            data_dir: data_dir.to_path_buf(),
243            child_handle,
244            crash_tx,
245            restart_count,
246            pid,
247            watchdog_handle: None,
248        };
249
250        // Start health monitoring
251        process.watchdog_handle = Some(process.spawn_watchdog());
252
253        Ok(process)
254    }
255
256    /// Run the post-initialization discovery sequence:
257    /// plugin/get_components, and for each component:
258    /// plugin/get_settings_schema, plugin/get_settings, plugin/config_actions.
259    /// 对 ActionExecutor 组件还会额外调用 plugin/supported_target_types
260    /// 和 plugin/supported_actions,收集真实的 ResultAction 列表。
261    /// 插件级元数据由宿主从清单构造,不在本序列。
262    pub async fn discover_components(&self) -> Result<InitResult, ProtocolError> {
263        let plugin_id = self.plugin_id.clone();
264
265        // plugin/get_components
266        let components: Vec<ComponentDescriptor> = self
267            .client
268            .call(
269                plugin_methods::GET_COMPONENTS,
270                serde_json::Value::Null,
271                Duration::from_secs(5),
272            )
273            .await?;
274
275        // 为每个组件拉取 schema、settings、config_actions。
276        // 对 ActionExecutor 组件额外拉取 supported_actions。
277        let mut settings_schemas = Vec::new();
278        let mut settings_values = Vec::new();
279        let mut config_actions_map = Vec::new();
280        let mut executor_actions_map: Vec<(String, Vec<zerolaunch_plugin_api::ResultAction>)> =
281            Vec::new();
282        let mut default_enabled_map = Vec::new();
283        let mut interaction_policy_map = Vec::new();
284        let mut keyword_optimizer_info_map = Vec::new();
285
286        for comp in &components {
287            let schema: Vec<zerolaunch_plugin_api::config::SettingDefinition> = self
288                .client
289                .call(
290                    plugin_methods::GET_SETTINGS_SCHEMA,
291                    GetSettingsSchemaParams {
292                        component_id: comp.component_id.clone(),
293                    },
294                    Duration::from_secs(5),
295                )
296                .await?;
297            settings_schemas.push((comp.component_id.clone(), schema));
298
299            let settings: serde_json::Value = self
300                .client
301                .call(
302                    plugin_methods::GET_SETTINGS,
303                    GetSettingsParams {
304                        component_id: comp.component_id.clone(),
305                    },
306                    Duration::from_secs(5),
307                )
308                .await?;
309            settings_values.push((comp.component_id.clone(), settings));
310
311            let actions: Vec<zerolaunch_plugin_api::config::ConfigActionDef> = self
312                .client
313                .call(
314                    plugin_methods::CONFIG_ACTIONS,
315                    ConfigActionsParams {
316                        component_id: comp.component_id.clone(),
317                    },
318                    Duration::from_secs(5),
319                )
320                .await?;
321            config_actions_map.push((comp.component_id.clone(), actions));
322
323            // 默认启用状态:新协议方法(老 SDK 返回 METHOD_NOT_FOUND 时回退默认 true)。
324            let default_enabled: bool = self
325                .client
326                .call(
327                    plugin_methods::GET_DEFAULT_ENABLED,
328                    GetDefaultEnabledParams {
329                        component_id: comp.component_id.clone(),
330                    },
331                    Duration::from_secs(5),
332                )
333                .await
334                .unwrap_or(true);
335            default_enabled_map.push((comp.component_id.clone(), default_enabled));
336
337            // 交互策略为插件级语义(PanelInteraction 属于 Plugin trait,DataSource/
338            // Executor 组件无策略):仅对 Plugin 种类拉取一次,避免多组件插件 N 次冗余 RPC。
339            // 初始值供构造缓存,查询/设置变更期间由 RemoteComponent 刷新。
340            if matches!(comp.kind, ComponentKind::Plugin) {
341                let policy: PanelInteraction = self
342                    .client
343                    .call(
344                        plugin_methods::INTERACTION_POLICY,
345                        InteractionPolicyParams {
346                            component_id: comp.component_id.clone(),
347                        },
348                        Duration::from_secs(5),
349                    )
350                    .await
351                    .unwrap_or_default();
352                interaction_policy_map.push((comp.component_id.clone(), policy));
353            }
354            // KeywordOptimizer 优化属性(input_source / priority 为声明字段,
355            // 仅对 KeywordOptimizer 种类拉取一次并在设置变更期间由 RemoteComponent 刷新)。
356            if matches!(comp.kind, ComponentKind::KeywordOptimizer) {
357                let info: KeywordOptimizerInfo = self
358                    .client
359                    .call(
360                        plugin_methods::KEYWORD_OPTIMIZER_INFO,
361                        KeywordOptimizerInfoParams {
362                            component_id: comp.component_id.clone(),
363                        },
364                        Duration::from_secs(5),
365                    )
366                    .await
367                    .unwrap_or_else(|_| KeywordOptimizerInfo::fallback(comp.priority));
368                keyword_optimizer_info_map.push((comp.component_id.clone(), info));
369            }
370
371            // 对 ActionExecutor 组件,遍历每种支持的 target_type 拉取
372            // supported_actions,按 (id, label) 去重后合并。
373            if matches!(comp.kind, ComponentKind::ActionExecutor { .. }) {
374                let target_types: Vec<zerolaunch_plugin_api::TargetType> = self
375                    .client
376                    .call(
377                        plugin_methods::SUPPORTED_TARGET_TYPES,
378                        SupportedTargetTypesParams {
379                            component_id: comp.component_id.clone(),
380                        },
381                        Duration::from_secs(5),
382                    )
383                    .await?;
384
385                let mut all_actions: Vec<zerolaunch_plugin_api::ResultAction> = Vec::new();
386                let mut seen = std::collections::HashSet::new();
387                for tt in &target_types {
388                    let actions: Vec<zerolaunch_plugin_api::ResultAction> = self
389                        .client
390                        .call(
391                            plugin_methods::SUPPORTED_ACTIONS,
392                            SupportedActionsParams {
393                                component_id: comp.component_id.clone(),
394                                target_type: *tt,
395                            },
396                            Duration::from_secs(5),
397                        )
398                        .await?;
399                    for a in actions {
400                        // 按 (id, label) 去重 — 同一动作可能被多个 target_type 返回
401                        if seen.insert((a.id.clone(), a.label.clone())) {
402                            all_actions.push(a);
403                        }
404                    }
405                }
406                executor_actions_map.push((comp.component_id.clone(), all_actions));
407            }
408        }
409
410        Ok(InitResult {
411            plugin_id,
412            components,
413            settings_schemas,
414            settings_values,
415            config_actions_map,
416            executor_actions_map,
417            default_enabled_map,
418            interaction_policy_map,
419            keyword_optimizer_info_map,
420        })
421    }
422
423    /// 生成一个看门狗任务,监控子进程的生命周期。
424    /// 使用事件驱动的等待(`child.wait().await`)——**没有轮询循环**。
425    ///
426    /// 当进程退出时:
427    /// - 如果状态已经是 `Stopped`(优雅关闭),静默返回。
428    /// - 如果 `auto_restart=false`,将状态设为 `Stopped` 并返回。
429    /// - 否则,将 `plugin_id` 发到 `crash_tx` 上,通知 `PluginHostManager`
430    ///   重新拉起。管理者(crash_loop)负责追踪重启次数和强制
431    ///   `max_restart` 上限。
432    ///
433    /// 任务完成即进程已退出;返回的 JoinHandle 供 shutdown 等待。
434    pub fn spawn_watchdog(&self) -> tokio::task::JoinHandle<()> {
435        let plugin_id = self.plugin_id.clone();
436        let state = self.state.clone();
437        let child_handle = self.child_handle.clone();
438        let auto_restart = self.manifest.runtime.auto_restart;
439        let restart_count = self.restart_count;
440        let crash_tx = self.crash_tx.clone();
441
442        tokio::spawn(async move {
443            // 从 Mutex 中取出子进程句柄,然后立即释放锁。
444            // 关键:parking_lot::Mutex 绝对不能在 .await 期间持有。
445            let mut child = {
446                let mut guard = child_handle.lock();
447                match guard.take() {
448                    Some(c) => c,
449                    None => return, // 已被取走(例如被先前的 shutdown 取走)
450                }
451            };
452
453            // 事件驱动等待:tokio 挂起此任务,直到进程退出。
454            // 零轮询——运行时只在 OS 通知进程终止时唤醒我们。
455            let status = match child.wait().await {
456                Ok(s) => s,
457                Err(e) => {
458                    warn!("Plugin {} wait error: {}", plugin_id, e);
459                    return;
460                }
461            };
462
463            info!(
464                "Plugin {} process exited with status: {:?}",
465                plugin_id, status
466            );
467
468            // 如果是优雅关闭(PluginProcess::shutdown 已将状态设为 Stopped),不重启。
469            if matches!(*state.read(), ProcessState::Stopped) {
470                debug!(
471                    "Plugin {} was gracefully stopped, not restarting",
472                    plugin_id
473                );
474                return;
475            }
476
477            if !auto_restart {
478                *state.write() = ProcessState::Stopped;
479                info!("Plugin {} auto-restart disabled, stopped", plugin_id);
480                return;
481            }
482
483            // 记录崩溃(仅用于观测)并通知管理者。
484            // crash_loop 通过 PluginRestartContext 追踪持久化计数
485            // 并强制 max_restart 上限——看门狗只负责检测。
486            *state.write() = ProcessState::Crashed {
487                restarts: restart_count + 1,
488                last_error: "process exited unexpectedly".into(),
489            };
490            info!(
491                "Plugin {} crashed (restart #{} so far), notifying manager",
492                plugin_id,
493                restart_count + 1
494            );
495
496            if crash_tx.send(plugin_id.clone()).await.is_err() {
497                debug!("Plugin {} crash notification channel closed", plugin_id);
498            }
499        })
500    }
501
502    /// 优雅关闭:关闭 stdin(SDK 进程唯一退出路径是 stdin EOF)→
503    /// 等待进程退出(watchdog 完成即进程退出)→ 超时则强杀。
504    pub async fn shutdown(mut self, timeout: Duration) {
505        let plugin_id = self.plugin_id.clone();
506        let pid = self.pid;
507        info!("Shutting down plugin {}", plugin_id);
508
509        // Step 1: 先标记 Stopped,让 watchdog 检测到后不触发重启。
510        // 必须早于任何终止动作,消除进程退出到状态更新之间的 TOCTOU 窗口。
511        *self.state.write() = ProcessState::Stopped;
512
513        // Step 2: 关闭 stdin → SDK 读任务 EOF → 进程自行退出。
514        // 写循环持有 ChildStdin 且随 client Arc 释放才退出(host-call 任务
515        // 持有 clone 形成循环等待),必须显式中止写循环。
516        self.client.close_transport();
517
518        // Step 3: 等待进程退出(watchdog 独占 Child 并 wait,完成即退出),
519        // 超时则强杀(进程卡死无法响应 EOF 时的兜底)。
520        let watchdog = self
521            .watchdog_handle
522            .take()
523            .expect("watchdog 在 spawn 时设置");
524        if tokio::time::timeout(timeout, watchdog).await.is_err() {
525            if let Some(pid) = pid {
526                force_kill_process(pid);
527            }
528        }
529
530        info!("Plugin {} shut down", plugin_id);
531    }
532}
533
534/// 基于 JSON-RPC 客户端的宿主事件推送实现。
535/// 仅本文件内使用:每次 spawn 为一个子进程构造,注入给 HostCallHandler。
536///
537/// `notify` 只做非阻塞投递:宿主回调可能运行在无 tokio 运行时的线程上
538/// (如 global-hotkey 的 Win32 窗口过程)。帧的编码与写出由转发任务负责
539/// (该任务运行在 tokio 运行时内)。
540struct ClientEventSink {
541    /// 待推送通知队列(方法名 + 载荷);接收端由转发任务持有。
542    /// 容量有界:插件进程不读 stdin 时通过 try_send 丢事件,避免宿主内存无界增长。
543    tx: mpsc::Sender<(String, serde_json::Value)>,
544}
545
546impl PluginEventSink for ClientEventSink {
547    fn notify(&self, method: &str, params: serde_json::Value) {
548        // 队列满(插件进程读得太慢)或通道关闭(进程已退出)时丢弃:
549        // 事件推送是尽力而为,不得反压宿主回调线程。
550        if self.tx.try_send((method.to_string(), params)).is_err() {
551            tracing::debug!("插件事件队列已满或已关闭,丢弃事件: {}", method);
552        }
553    }
554}
555
556/// 启动事件转发任务:把队列中的通知依次编码成帧写往插件进程 stdin。
557/// 参数:client - 出站 JSON-RPC 客户端(宿主 → 插件方向)。
558/// 返回:注入给 HostCallHandler 的事件推送通道。
559///
560/// 投递与写出分离:宿主回调线程无运行时上下文,`notify` 只做非阻塞投递,写出固定在带运行时的任务里。
561fn spawn_event_forwarder(client: Arc<JsonRpcClient>) -> Arc<dyn PluginEventSink> {
562    let (event_tx, mut event_rx) = mpsc::channel::<(String, serde_json::Value)>(64);
563    tokio::spawn(async move {
564        while let Some((method, params)) = event_rx.recv().await {
565            if let Err(e) = client.notify(&method, params).await {
566                warn!("推送插件事件失败 ({}): {}", method, e);
567            }
568        }
569    });
570    Arc::new(ClientEventSink { tx: event_tx })
571}
572
573/// 强制终止指定 PID 的进程。
574/// 使用平台原生的 kill 命令(Windows: taskkill, Unix: kill -9)。
575pub(crate) fn force_kill_process(pid: u32) {
576    #[cfg(target_os = "windows")]
577    {
578        let _ = std::process::Command::new("taskkill")
579            .args(["/F", "/T", "/PID", &pid.to_string()])
580            .output();
581    }
582    #[cfg(not(target_os = "windows"))]
583    {
584        let _ = std::process::Command::new("kill")
585            .args(["-9", &pid.to_string()])
586            .output();
587    }
588}
589
590/// Maximum size of a plugin stderr log file (10 MB).
591const MAX_LOG_SIZE: u64 = 10 * 1024 * 1024;
592
593async fn append_to_log(log_path: &Path, text: &str) {
594    use tokio::io::AsyncWriteExt;
595
596    // Rotate log if it exceeds the size cap
597    if let Ok(meta) = tokio::fs::metadata(log_path).await {
598        if meta.len() > MAX_LOG_SIZE {
599            // Simple rotation: keep only one old file
600            let rotated = log_path.with_extension("log.1");
601            let _ = tokio::fs::remove_file(&rotated).await;
602            let _ = tokio::fs::rename(log_path, &rotated).await;
603        }
604    }
605
606    if let Ok(mut file) = tokio::fs::OpenOptions::new()
607        .create(true)
608        .append(true)
609        .open(log_path)
610        .await
611    {
612        let _ = file.write_all(text.as_bytes()).await;
613    }
614}
615
616/// 判定插件协议版本与宿主是否兼容:major 段相同即兼容(semver 兼容语义)。
617///
618/// 宿主驱动模型下 minor 差异双向兼容——宿主只调用自己知道的方法,
619/// 新增方法对旧插件可选且宿主容错 METHOD_NOT_FOUND;major 不同视为
620/// 破坏性载荷变更。无法解析的版本声明视为不兼容(安全默认)。
621fn protocol_version_compatible(plugin_protocol: &str, host_protocol: &str) -> bool {
622    let major = |v: &str| v.split('.').next().and_then(|m| m.parse::<u64>().ok());
623    match (major(plugin_protocol), major(host_protocol)) {
624        (Some(p), Some(h)) => p == h,
625        _ => false,
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::protocol_version_compatible;
632    use super::{spawn_event_forwarder, IncomingRequest, JsonRpcClient};
633    use serde_json::json;
634    use tokio::io::{duplex, BufReader};
635    use tokio::sync::mpsc;
636    use zerolaunch_plugin_protocol::jsonrpc::Message;
637
638    /// 宿主事件推送在无 tokio 运行时线程上调用不得 panic(覆盖 Win32 窗口过程回调)。
639    #[tokio::test]
640    async fn event_sink_notify_is_callable_without_runtime() {
641        use crate::transport::codec::read_frame;
642
643        let (client_out, plugin_in) = duplex(64 * 1024);
644        let (request_tx, _request_rx) = mpsc::channel::<IncomingRequest>(4);
645        let (notification_tx, _notification_rx) = mpsc::channel::<(String, serde_json::Value)>(4);
646        let client = JsonRpcClient::new(
647            BufReader::new(tokio::io::empty()),
648            client_out,
649            request_tx,
650            notification_tx,
651        );
652        let sink = spawn_event_forwarder(client);
653
654        std::thread::spawn(move || {
655            sink.notify("plugin/timer.fired", json!({ "id": 7 }));
656        })
657        .join()
658        .expect("无 tokio 运行时线程上的 notify 不得 panic");
659
660        let mut reader = BufReader::new(plugin_in);
661        let body = read_frame(&mut reader).await.expect("事件应被写成帧");
662        match serde_json::from_slice::<Message>(&body).expect("帧体是 JSON-RPC 消息") {
663            Message::Notification(n) => {
664                assert_eq!(n.method, "plugin/timer.fired");
665                assert_eq!(n.params["id"], 7);
666            }
667            other => panic!("期望通知,得到 {other:?}"),
668        }
669    }
670
671    /// major 相同(含 minor 差异)视为兼容:宿主驱动模型下双向安全。
672    #[test]
673    fn same_major_is_compatible() {
674        assert!(protocol_version_compatible("1.0", "1.0"));
675        assert!(protocol_version_compatible("1.0", "1.2"));
676        assert!(protocol_version_compatible("1.5", "1.0"));
677        assert!(protocol_version_compatible("1.0.3", "1.0"));
678    }
679
680    /// major 不同 = 破坏性变更,拒绝。
681    #[test]
682    fn different_major_is_incompatible() {
683        assert!(!protocol_version_compatible("2.0", "1.0"));
684        assert!(!protocol_version_compatible("1.0", "2.0"));
685    }
686
687    /// 无法解析的版本声明(非数字 major)视为不兼容(安全默认)。
688    #[test]
689    fn unparseable_version_is_incompatible() {
690        assert!(!protocol_version_compatible("dev", "1.0"));
691        assert!(!protocol_version_compatible("", "1.0"));
692        assert!(!protocol_version_compatible("1.0", ""));
693    }
694}