use parking_lot::RwLock;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use zerolaunch_plugin_api::config::{ConfigActionDef, SettingDefinition};
use zerolaunch_plugin_api::{KeywordInputSource, PanelInteraction, ResultAction};
use zerolaunch_plugin_protocol::manifest::Manifest;
use zerolaunch_plugin_protocol::messages::*;
use zerolaunch_plugin_protocol::methods::plugin as plugin_methods;
use zerolaunch_plugin_protocol::{ProtocolError, PROTOCOL_VERSION};
use crate::client::{IncomingRequest, JsonRpcClient};
use crate::host_dispatch::{HostCallHandler, PluginEventSink};
use crate::transport::stdio::StdioTransport;
#[derive(Debug, Clone)]
pub enum ProcessState {
Starting,
Running,
Crashed { restarts: u32, last_error: String },
Stopped,
Error(String),
}
pub struct InitResult {
pub plugin_id: String,
pub components: Vec<ComponentDescriptor>,
pub settings_schemas: Vec<(String, Vec<SettingDefinition>)>,
pub settings_values: Vec<(String, serde_json::Value)>,
pub config_actions_map: Vec<(String, Vec<ConfigActionDef>)>,
pub executor_actions_map: Vec<(String, Vec<ResultAction>)>,
pub default_enabled_map: Vec<(String, bool)>,
pub interaction_policy_map: Vec<(String, PanelInteraction)>,
pub keyword_optimizer_info_map: Vec<(String, KeywordOptimizerInfo)>,
}
pub struct PluginProcess {
pub plugin_id: String,
pub manifest: Manifest,
pub state: Arc<RwLock<ProcessState>>,
pub client: Arc<JsonRpcClient>,
pub data_dir: PathBuf,
child_handle: Arc<parking_lot::Mutex<Option<tokio::process::Child>>>,
crash_tx: mpsc::Sender<String>,
pub restart_count: u32,
pub pid: Option<u32>,
watchdog_handle: Option<tokio::task::JoinHandle<()>>,
}
impl PluginProcess {
#[allow(clippy::too_many_arguments)]
pub async fn spawn(
manifest: &Manifest,
plugin_dir: &Path,
data_dir: &Path,
log_dir: &Path,
host_call_handler: Arc<dyn HostCallHandler>,
crash_tx: mpsc::Sender<String>,
restart_count: u32,
locale: &str,
) -> Result<Self, ProtocolError> {
let plugin_id = manifest.plugin.id.clone();
let cmd_path = plugin_dir.join(&manifest.runtime.command);
if !cmd_path.exists() {
return Err(ProtocolError::InvalidFrame(format!(
"plugin command not found: {}",
cmd_path.display()
)));
}
let env = vec![
("ZEROLAUNCH_PLUGIN_ID".to_string(), plugin_id.clone()),
(
"ZEROLAUNCH_DATA_DIR".to_string(),
data_dir.to_string_lossy().to_string(),
),
(
"ZEROLAUNCH_LOG_DIR".to_string(),
log_dir.to_string_lossy().to_string(),
),
];
info!("Spawning plugin {}: {:?}", plugin_id, cmd_path);
let transport =
StdioTransport::spawn(&cmd_path, &manifest.runtime.args, plugin_dir, &env).await?;
let pid = transport.pid();
let StdioTransport {
child,
stdin: child_stdin,
stdout: child_stdout,
stderr: child_stderr,
} = transport;
let child_handle = Arc::new(parking_lot::Mutex::new(Some(child)));
let log_file = log_dir.join(format!("{}.log", plugin_id));
let stderr_pid = plugin_id.clone();
let mut stderr_reader = child_stderr;
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
match tokio::io::AsyncReadExt::read(&mut stderr_reader, &mut buf).await {
Ok(0) => break,
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]);
let _ = append_to_log(&log_file, &text).await;
}
Err(e) => {
debug!("stderr read error for {}: {}", stderr_pid, e);
break;
}
}
}
});
let (incoming_request_tx, mut incoming_request_rx) = mpsc::channel::<IncomingRequest>(64);
let (incoming_notification_tx, _incoming_notification_rx) =
mpsc::channel::<(String, serde_json::Value)>(64);
let client = JsonRpcClient::new(
child_stdout,
child_stdin,
incoming_request_tx,
incoming_notification_tx,
);
host_call_handler.attach_event_sink(spawn_event_forwarder(client.clone()));
let hc = host_call_handler.clone();
let cl = client.clone();
tokio::spawn(async move {
while let Some(incoming) = incoming_request_rx.recv().await {
debug!(
"处理插件请求: method={} params={}",
incoming.method,
zerolaunch_plugin_protocol::codec::summarize_value(&incoming.params)
);
let result = hc.handle_host_call(&incoming.method, incoming.params).await;
match result {
Ok(val) => {
if let Err(e) = cl.respond_ok(incoming.id, val).await {
error!("Failed to send response for {}: {}", incoming.method, e);
}
}
Err(err) => {
if let Err(e) = cl.respond_err(incoming.id, err).await {
error!(
"Failed to send error response for {}: {}",
incoming.method, e
);
}
}
}
}
});
let host_version = env!("CARGO_PKG_VERSION").to_string();
let protocol_version = zerolaunch_plugin_protocol::PROTOCOL_VERSION.to_string();
let init_params = InitializeParams {
host_version: host_version.clone(),
protocol_version: protocol_version.clone(),
data_dir: data_dir.to_string_lossy().to_string(),
log_dir: log_dir.to_string_lossy().to_string(),
plugin_id: plugin_id.clone(),
locale: locale.to_string(),
};
let init_result: InitializeResult = client
.call(
plugin_methods::INITIALIZE,
init_params,
Duration::from_secs(manifest.runtime.startup_timeout),
)
.await?;
info!(
"Plugin {} initialized (pid={:?}), result: {:?}",
plugin_id, pid, init_result
);
if !protocol_version_compatible(&init_result.protocol_version, PROTOCOL_VERSION) {
return Err(ProtocolError::ProtocolVersionIncompatible {
plugin: plugin_id.clone(),
expected: PROTOCOL_VERSION.to_string(),
got: init_result.protocol_version,
});
}
let mut process = Self {
plugin_id: plugin_id.clone(),
manifest: manifest.clone(),
state: Arc::new(RwLock::new(ProcessState::Running)),
client,
data_dir: data_dir.to_path_buf(),
child_handle,
crash_tx,
restart_count,
pid,
watchdog_handle: None,
};
process.watchdog_handle = Some(process.spawn_watchdog());
Ok(process)
}
pub async fn discover_components(&self) -> Result<InitResult, ProtocolError> {
let plugin_id = self.plugin_id.clone();
let components: Vec<ComponentDescriptor> = self
.client
.call(
plugin_methods::GET_COMPONENTS,
serde_json::Value::Null,
Duration::from_secs(5),
)
.await?;
let mut settings_schemas = Vec::new();
let mut settings_values = Vec::new();
let mut config_actions_map = Vec::new();
let mut executor_actions_map: Vec<(String, Vec<zerolaunch_plugin_api::ResultAction>)> =
Vec::new();
let mut default_enabled_map = Vec::new();
let mut interaction_policy_map = Vec::new();
let mut keyword_optimizer_info_map = Vec::new();
for comp in &components {
let schema: Vec<zerolaunch_plugin_api::config::SettingDefinition> = self
.client
.call(
plugin_methods::GET_SETTINGS_SCHEMA,
GetSettingsSchemaParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await?;
settings_schemas.push((comp.component_id.clone(), schema));
let settings: serde_json::Value = self
.client
.call(
plugin_methods::GET_SETTINGS,
GetSettingsParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await?;
settings_values.push((comp.component_id.clone(), settings));
let actions: Vec<zerolaunch_plugin_api::config::ConfigActionDef> = self
.client
.call(
plugin_methods::CONFIG_ACTIONS,
ConfigActionsParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await?;
config_actions_map.push((comp.component_id.clone(), actions));
let default_enabled: bool = self
.client
.call(
plugin_methods::GET_DEFAULT_ENABLED,
GetDefaultEnabledParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await
.unwrap_or(true);
default_enabled_map.push((comp.component_id.clone(), default_enabled));
if matches!(comp.kind, ComponentKind::Plugin) {
let policy: PanelInteraction = self
.client
.call(
plugin_methods::INTERACTION_POLICY,
InteractionPolicyParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await
.unwrap_or_default();
interaction_policy_map.push((comp.component_id.clone(), policy));
}
if matches!(comp.kind, ComponentKind::KeywordOptimizer) {
let info: KeywordOptimizerInfo = self
.client
.call(
plugin_methods::KEYWORD_OPTIMIZER_INFO,
KeywordOptimizerInfoParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await
.unwrap_or(KeywordOptimizerInfo {
input_source: KeywordInputSource::Refined,
priority: comp.priority,
});
keyword_optimizer_info_map.push((comp.component_id.clone(), info));
}
if matches!(comp.kind, ComponentKind::ActionExecutor { .. }) {
let target_types: Vec<zerolaunch_plugin_api::TargetType> = self
.client
.call(
plugin_methods::SUPPORTED_TARGET_TYPES,
SupportedTargetTypesParams {
component_id: comp.component_id.clone(),
},
Duration::from_secs(5),
)
.await?;
let mut all_actions: Vec<zerolaunch_plugin_api::ResultAction> = Vec::new();
let mut seen = std::collections::HashSet::new();
for tt in &target_types {
let actions: Vec<zerolaunch_plugin_api::ResultAction> = self
.client
.call(
plugin_methods::SUPPORTED_ACTIONS,
SupportedActionsParams {
component_id: comp.component_id.clone(),
target_type: *tt,
},
Duration::from_secs(5),
)
.await?;
for a in actions {
if seen.insert((a.id.clone(), a.label.clone())) {
all_actions.push(a);
}
}
}
executor_actions_map.push((comp.component_id.clone(), all_actions));
}
}
Ok(InitResult {
plugin_id,
components,
settings_schemas,
settings_values,
config_actions_map,
executor_actions_map,
default_enabled_map,
interaction_policy_map,
keyword_optimizer_info_map,
})
}
pub fn spawn_watchdog(&self) -> tokio::task::JoinHandle<()> {
let plugin_id = self.plugin_id.clone();
let state = self.state.clone();
let child_handle = self.child_handle.clone();
let auto_restart = self.manifest.runtime.auto_restart;
let restart_count = self.restart_count;
let crash_tx = self.crash_tx.clone();
tokio::spawn(async move {
let mut child = {
let mut guard = child_handle.lock();
match guard.take() {
Some(c) => c,
None => return, }
};
let status = match child.wait().await {
Ok(s) => s,
Err(e) => {
warn!("Plugin {} wait error: {}", plugin_id, e);
return;
}
};
info!(
"Plugin {} process exited with status: {:?}",
plugin_id, status
);
if matches!(*state.read(), ProcessState::Stopped) {
debug!(
"Plugin {} was gracefully stopped, not restarting",
plugin_id
);
return;
}
if !auto_restart {
*state.write() = ProcessState::Stopped;
info!("Plugin {} auto-restart disabled, stopped", plugin_id);
return;
}
*state.write() = ProcessState::Crashed {
restarts: restart_count + 1,
last_error: "process exited unexpectedly".into(),
};
info!(
"Plugin {} crashed (restart #{} so far), notifying manager",
plugin_id,
restart_count + 1
);
if crash_tx.send(plugin_id.clone()).await.is_err() {
debug!("Plugin {} crash notification channel closed", plugin_id);
}
})
}
pub async fn shutdown(mut self, timeout: Duration) {
let plugin_id = self.plugin_id.clone();
let pid = self.pid;
info!("Shutting down plugin {}", plugin_id);
*self.state.write() = ProcessState::Stopped;
self.client.close_transport();
let watchdog = self
.watchdog_handle
.take()
.expect("watchdog 在 spawn 时设置");
if tokio::time::timeout(timeout, watchdog).await.is_err() {
if let Some(pid) = pid {
force_kill_process(pid);
}
}
info!("Plugin {} shut down", plugin_id);
}
}
struct ClientEventSink {
tx: mpsc::Sender<(String, serde_json::Value)>,
}
impl PluginEventSink for ClientEventSink {
fn notify(&self, method: &str, params: serde_json::Value) {
if self.tx.try_send((method.to_string(), params)).is_err() {
tracing::debug!("插件事件队列已满或已关闭,丢弃事件: {}", method);
}
}
}
fn spawn_event_forwarder(client: Arc<JsonRpcClient>) -> Arc<dyn PluginEventSink> {
let (event_tx, mut event_rx) = mpsc::channel::<(String, serde_json::Value)>(64);
tokio::spawn(async move {
while let Some((method, params)) = event_rx.recv().await {
if let Err(e) = client.notify(&method, params).await {
warn!("推送插件事件失败 ({}): {}", method, e);
}
}
});
Arc::new(ClientEventSink { tx: event_tx })
}
pub(crate) fn force_kill_process(pid: u32) {
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.output();
}
#[cfg(not(target_os = "windows"))]
{
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
}
}
const MAX_LOG_SIZE: u64 = 10 * 1024 * 1024;
async fn append_to_log(log_path: &Path, text: &str) {
use tokio::io::AsyncWriteExt;
if let Ok(meta) = tokio::fs::metadata(log_path).await {
if meta.len() > MAX_LOG_SIZE {
let rotated = log_path.with_extension("log.1");
let _ = tokio::fs::remove_file(&rotated).await;
let _ = tokio::fs::rename(log_path, &rotated).await;
}
}
if let Ok(mut file) = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.await
{
let _ = file.write_all(text.as_bytes()).await;
}
}
fn protocol_version_compatible(plugin_protocol: &str, host_protocol: &str) -> bool {
let major = |v: &str| v.split('.').next().and_then(|m| m.parse::<u64>().ok());
match (major(plugin_protocol), major(host_protocol)) {
(Some(p), Some(h)) => p == h,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::protocol_version_compatible;
use super::{spawn_event_forwarder, IncomingRequest, JsonRpcClient};
use serde_json::json;
use tokio::io::{duplex, BufReader};
use tokio::sync::mpsc;
use zerolaunch_plugin_protocol::jsonrpc::Message;
#[tokio::test]
async fn event_sink_notify_is_callable_without_runtime() {
use crate::transport::codec::read_frame;
let (client_out, plugin_in) = duplex(64 * 1024);
let (request_tx, _request_rx) = mpsc::channel::<IncomingRequest>(4);
let (notification_tx, _notification_rx) = mpsc::channel::<(String, serde_json::Value)>(4);
let client = JsonRpcClient::new(
BufReader::new(tokio::io::empty()),
client_out,
request_tx,
notification_tx,
);
let sink = spawn_event_forwarder(client);
std::thread::spawn(move || {
sink.notify("plugin/timer.fired", json!({ "id": 7 }));
})
.join()
.expect("无 tokio 运行时线程上的 notify 不得 panic");
let mut reader = BufReader::new(plugin_in);
let body = read_frame(&mut reader).await.expect("事件应被写成帧");
match serde_json::from_slice::<Message>(&body).expect("帧体是 JSON-RPC 消息") {
Message::Notification(n) => {
assert_eq!(n.method, "plugin/timer.fired");
assert_eq!(n.params["id"], 7);
}
other => panic!("期望通知,得到 {other:?}"),
}
}
#[test]
fn same_major_is_compatible() {
assert!(protocol_version_compatible("1.0", "1.0"));
assert!(protocol_version_compatible("1.0", "1.2"));
assert!(protocol_version_compatible("1.5", "1.0"));
assert!(protocol_version_compatible("1.0.3", "1.0"));
}
#[test]
fn different_major_is_incompatible() {
assert!(!protocol_version_compatible("2.0", "1.0"));
assert!(!protocol_version_compatible("1.0", "2.0"));
}
#[test]
fn unparseable_version_is_incompatible() {
assert!(!protocol_version_compatible("dev", "1.0"));
assert!(!protocol_version_compatible("", "1.0"));
assert!(!protocol_version_compatible("1.0", ""));
}
}