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 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 host_call_handler.attach_event_sink(spawn_event_forwarder(client.clone()));
170
171 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 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 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 process.watchdog_handle = Some(process.spawn_watchdog());
252
253 Ok(process)
254 }
255
256 pub async fn discover_components(&self) -> Result<InitResult, ProtocolError> {
263 let plugin_id = self.plugin_id.clone();
264
265 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 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 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 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 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 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 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 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 let mut child = {
446 let mut guard = child_handle.lock();
447 match guard.take() {
448 Some(c) => c,
449 None => return, }
451 };
452
453 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 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 *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 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 *self.state.write() = ProcessState::Stopped;
512
513 self.client.close_transport();
517
518 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
534struct ClientEventSink {
541 tx: mpsc::Sender<(String, serde_json::Value)>,
544}
545
546impl PluginEventSink for ClientEventSink {
547 fn notify(&self, method: &str, params: serde_json::Value) {
548 if self.tx.try_send((method.to_string(), params)).is_err() {
551 tracing::debug!("插件事件队列已满或已关闭,丢弃事件: {}", method);
552 }
553 }
554}
555
556fn 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
573pub(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
590const 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 if let Ok(meta) = tokio::fs::metadata(log_path).await {
598 if meta.len() > MAX_LOG_SIZE {
599 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
616fn 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::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 #[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 #[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 #[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}