1use 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#[derive(Debug, Clone)]
23pub enum ProcessState {
24 Starting,
25 Running,
26 Crashed { restarts: u32, last_error: String },
27 Stopped,
28 Error(String),
29}
30
31pub 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 pub executor_actions_map: Vec<(String, Vec<ResultAction>)>,
42 pub default_enabled_map: Vec<(String, bool)>,
44 pub interaction_policy_map: Vec<(String, PanelInteraction)>,
48 pub keyword_optimizer_info_map: Vec<(String, KeywordOptimizerInfo)>,
52}
53
54pub 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 child_handle: Arc<parking_lot::Mutex<Option<tokio::process::Child>>>,
63 crash_tx: mpsc::Sender<String>,
65 pub restart_count: u32,
68 pub pid: Option<u32>,
70 watchdog_handle: Option<tokio::task::JoinHandle<()>>,
72}
73
74impl PluginProcess {
75 #[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 restart_count: u32,
93 locale: &str,
95 ) -> Result<Self, ProtocolError> {
96 let plugin_id = manifest.plugin.id.clone();
97
98 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 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 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 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 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 host_call_handler.attach_event_sink(spawn_event_forwarder(client.clone()));
163
164 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 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 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 process.watchdog_handle = Some(process.spawn_watchdog());
245
246 Ok(process)
247 }
248
249 pub async fn discover_components(&self) -> Result<InitResult, ProtocolError> {
256 let plugin_id = self.plugin_id.clone();
257
258 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 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 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 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 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 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 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 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 let mut child = {
439 let mut guard = child_handle.lock();
440 match guard.take() {
441 Some(c) => c,
442 None => return, }
444 };
445
446 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 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 *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 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 *self.state.write() = ProcessState::Stopped;
508
509 self.client.close_transport();
513
514 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
530struct ClientEventSink {
537 tx: mpsc::Sender<(String, serde_json::Value)>,
540}
541
542impl PluginEventSink for ClientEventSink {
543 fn notify(&self, method: &str, params: serde_json::Value) {
544 if self.tx.try_send((method.to_string(), params)).is_err() {
547 tracing::debug!("插件事件队列已满或已关闭,丢弃事件: {}", method);
548 }
549 }
550}
551
552fn 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
569pub(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
586const 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 if let Ok(meta) = tokio::fs::metadata(log_path).await {
594 if meta.len() > MAX_LOG_SIZE {
595 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
612fn 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::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 #[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 #[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 #[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}