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