From 1d1ccda42a311dbc33af8f54b0e9d7a2de6a33bd Mon Sep 17 00:00:00 2001 From: Zyy <0x@ainz.cc> Date: Mon, 26 Oct 2026 15:01:46 +0800 Subject: [PATCH] refactor(plugins): consolidate host lifecycle and diagnostics (#529) Co-authored-by: Zyy Co-authored-by: Codex --- .../src/model/plugins/instances.rs | 16 + .../src/use_case/plugins/instances.rs | 773 ----------------- .../plugins/instances/configuration.rs | 135 +++ .../use_case/plugins/instances/migration.rs | 216 +++++ .../src/use_case/plugins/instances/mod.rs | 15 + .../use_case/plugins/instances/mutations.rs | 312 +++++++ .../use_case/plugins/instances/publication.rs | 45 + .../src/use_case/plugins/instances/queries.rs | 122 +++ .../{instances.rs => instances/mod.rs} | 58 +- .../use_case/plugins/instances/queries.rs | 151 ++++ .../src/admin/plugins/instances.rs | 32 +- .../gateway-api/tests/admin/plugins/mod.rs | 19 + .../runtime/src/callback/accounts.rs | 12 +- .../runtime/src/callback/dispatch.rs | 245 ++++++ .../runtime/src/callback/http.rs | 8 +- .../runtime/src/callback/invocation.rs | 243 ++++++ .../runtime/src/callback/keys.rs | 2 +- .../runtime/src/callback/mod.rs | 528 +---------- .../runtime/src/callback/model.rs | 14 +- .../runtime/src/callback/ports.rs | 102 +++ .../runtime/src/callback/private_state.rs | 10 +- .../runtime/src/callback/upstream/mod.rs | 20 +- .../src/generation/prepare/diagnostics.rs | 13 +- .../runtime/src/rpc/admission.rs | 7 + .../gateway-plugin/runtime/src/rpc/calls.rs | 658 ++++++++++++++ .../runtime/src/rpc/contract.rs | 192 ++++ .../runtime/src/rpc/dispatch.rs | 2 +- .../gateway-plugin/runtime/src/rpc/mod.rs | 8 +- .../gateway-plugin/runtime/src/rpc/session.rs | 818 +----------------- .../runtime/tests/callback/dispatch.rs | 72 ++ .../runtime/tests/callback/mod.rs | 1 + .../runtime/tests/generation/observer.rs | 68 ++ .../tests/generation/prepare/diagnostics.rs | 17 +- .../gateway-plugin/runtime/tests/rpc/calls.rs | 54 ++ .../gateway-plugin/runtime/tests/rpc/mod.rs | 1 + .../runtime/tests/rpc/session.rs | 2 +- .../gateway-plugin/sdk/docs/capabilities.md | 2 +- .../gateway-plugin/sdk/src/call/host.rs | 22 +- .../sdk/src/call/upstream_adapter/mod.rs | 9 + .../gateway-plugin/sdk/src/client/http.rs | 17 +- .../gateway-plugin/sdk/src/client/keys.rs | 4 +- .../sdk/src/client/upstream_adapter.rs | 10 +- docs/api.md | 23 +- docs/architecture.md | 6 + docs/plugins.md | 3 +- frontend/src/api/modules/plugins/index.ts | 11 + frontend/src/i18n/locales/en/plugins.json | 20 +- .../src/i18n/locales/zh-Hans/plugins.json | 20 +- .../src/i18n/locales/zh-Hant/plugins.json | 20 +- .../plugins/components/PluginDetailModal.vue | 4 + .../plugins/components/PluginRuntimeLoad.vue | 73 ++ .../composables/usePluginFrameSession.ts | 321 +------ .../composables/usePluginManagement.ts | 10 +- frontend/src/views/plugins/constants.ts | 1 + .../plugins/management/bridge-contract.ts | 75 +- .../views/plugins/management/bridge-models.ts | 246 ++++++ .../plugins/management/bridge-runtime.ts | 294 +------ .../views/plugins/management/model-streams.ts | 268 ++++++ frontend/src/views/plugins/utils/catalog.ts | 4 +- 59 files changed, 3706 insertions(+), 2748 deletions(-) delete mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances.rs create mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances/configuration.rs create mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs create mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances/mod.rs create mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs create mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs create mode 100644 backend/crates/gateway-admin/src/use_case/plugins/instances/queries.rs rename backend/crates/gateway-admin/tests/use_case/plugins/{instances.rs => instances/mod.rs} (97%) create mode 100644 backend/crates/gateway-admin/tests/use_case/plugins/instances/queries.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/callback/dispatch.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/callback/invocation.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/callback/ports.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/rpc/calls.rs create mode 100644 backend/crates/gateway-plugin/runtime/src/rpc/contract.rs create mode 100644 backend/crates/gateway-plugin/runtime/tests/callback/dispatch.rs create mode 100644 backend/crates/gateway-plugin/runtime/tests/rpc/calls.rs create mode 100644 frontend/src/views/plugins/components/PluginRuntimeLoad.vue create mode 100644 frontend/src/views/plugins/management/bridge-models.ts create mode 100644 frontend/src/views/plugins/management/model-streams.ts diff --git a/backend/crates/gateway-admin/src/model/plugins/instances.rs b/backend/crates/gateway-admin/src/model/plugins/instances.rs index 01715a506..5dd803fae 100644 --- a/backend/crates/gateway-admin/src/model/plugins/instances.rs +++ b/backend/crates/gateway-admin/src/model/plugins/instances.rs @@ -84,6 +84,7 @@ pub struct PluginInstanceMutation { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginInstanceRuntimeStatus { Disabled, + Unknown, AwaitingPublication, Preparing, Running, @@ -108,6 +109,21 @@ pub struct PluginInstanceRuntime { pub actual_artifact_sha256: Option, pub failure: Option, pub draining_revisions: Vec, + pub load: Option, +} + +/// 当前发布会话的资源占用采样,计数可以重叠且不构成原子快照 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstanceRuntimeLoad { + /// 总在途名额,包含排队、取消确认和仍由响应流持有的调用 + pub active_calls: usize, + pub executing_calls: usize, + pub queued_calls: usize, + pub resuming_calls: usize, + /// 包含已返回但尚未由插件消费的回调结果 + pub pending_callbacks: usize, + /// 已交付首结果但尚未收到 End 的流 + pub active_streams: usize, } /// 可继续运行的旧接口提示,与无法启用的兼容性错误分开 diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances.rs deleted file mode 100644 index f4c13dbdc..000000000 --- a/backend/crates/gateway-admin/src/use_case/plugins/instances.rs +++ /dev/null @@ -1,773 +0,0 @@ -//! 插件实例的配置、停用、删除与私有状态迁移编排 - -use std::collections::{BTreeMap, BTreeSet}; - -use gateway_core::{ - policy::ClientApiKeyId, - routing::{AccountGroupId, extensions::ExtensionSetReference}, -}; -use secrecy::ExposeSecret; - -use super::PluginsService; -use crate::{ - model::{ - AdminError, AdminErrorKind, MutationContext, Revision, - plugins::instances::{ - ConfigurePluginInstance, PluginInstance, PluginInstanceMutation, - PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus, - PluginInstanceSnapshot, PluginInstanceView, - }, - plugins::state::{PluginStateCommit, PluginStateConfiguration}, - }, - use_case::{map_store_error, publish_committed}, -}; - -#[derive(Clone, Default)] -pub(super) struct PackageStatus { - warning: Option, - load_error: Option, -} - -impl PluginsService { - /// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误 - async fn package_status(&self, digest: &str) -> Result { - // 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包 - // 只缓存有限数量的确定结果,暂时性错误仍可重试 - let mut cache = self.compatibility.lock().await; - if let Some(warning) = cache.get(digest) { - return Ok(warning.clone()); - } - let artifact = self - .store - .load_artifact(digest) - .await - .map_err(|error| map_store_error(error, "plugin"))?; - let warning = match self - .inspector - .compatibility_warning(artifact.archive, digest.to_owned()) - .await - { - Ok(warning) => PackageStatus { - warning, - load_error: None, - }, - Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus { - warning: None, - load_error: Some(error.message().to_owned()), - }, - Err(error) => return Err(error), - }; - if cache.len() < 128 { - cache.insert(digest.to_owned(), warning.clone()); - } - Ok(warning) - } - - pub async fn instances(&self) -> Result, AdminError> { - let snapshot = self - .store - .load_instances() - .await - .map_err(|e| map_store_error(e, "plugin"))?; - let published = self.published.snapshot_for_diagnostics(); - let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get()); - let published_ready = published - .as_ref() - .is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve())); - let mut diagnostics = self - .preparation - .runtime_diagnostics( - &snapshot, - published_revision, - published - .as_ref() - .and_then(|snapshot| snapshot.extensions()), - ) - .await - .unwrap_or_default(); - let target_revision = snapshot.config_revision.get(); - let artifacts = self - .list() - .await? - .into_iter() - .map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata)) - .collect::>(); - let mut views = Vec::with_capacity(snapshot.instances.len()); - for instance in snapshot.instances { - let metadata = artifacts - .get(&instance.artifact_sha256) - .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; - let package_status = self.package_status(&instance.artifact_sha256).await?; - let configuration_required = package_status.load_error.is_none() - && !self - .preparation - .configuration_ready(instance.clone(), metadata) - .await?; - let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| { - let running = instance.enabled - && published_ready - && published_revision == Some(target_revision); - PluginInstanceRuntime { - status: if !instance.enabled { - PluginInstanceRuntimeStatus::Disabled - } else if running { - PluginInstanceRuntimeStatus::Running - } else { - PluginInstanceRuntimeStatus::AwaitingPublication - }, - actual_revision: running.then_some(instance.revision.get()), - actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()), - failure: None, - draining_revisions: Vec::new(), - } - }); - views.push(PluginInstanceView { - api_deprecations: if package_status.load_error.is_none() { - self.inspector.api_deprecations(metadata)? - } else { - Vec::new() - }, - configuration_required, - compatibility_warning: package_status.warning, - load_error: package_status.load_error, - running: runtime.status == PluginInstanceRuntimeStatus::Running, - published_revision, - runtime, - instance, - }); - } - Ok(views) - } - - pub async fn configure_instance( - &self, - id: Option<&str>, - input: ConfigurePluginInstance, - context: &MutationContext, - ) -> Result { - if id.is_some() && input.creation_id.is_some() { - return Err(AdminError::invalid("编辑配置不能使用创建标识")); - } - if id.is_none() && input.expected_revision.is_some() { - return Err(AdminError::invalid("创建配置不能指定已有配置版本")); - } - if let Some(creation_id) = &input.creation_id { - let parsed = uuid::Uuid::parse_str(creation_id) - .map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?; - if parsed.to_string() != *creation_id { - return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式")); - } - } - let snapshot = self - .store - .load_instances() - .await - .map_err(|e| map_store_error(e, "plugin"))?; - let existing = match id { - Some(id) => Some( - snapshot - .instances - .iter() - .find(|instance| instance.id == id) - .cloned() - .ok_or_else(|| AdminError::not_found("插件实例不存在"))?, - ), - None => input.creation_id.as_ref().and_then(|creation_id| { - snapshot - .instances - .iter() - .find(|instance| instance.id == *creation_id) - .cloned() - }), - }; - if let Some(existing) = &existing { - if id.is_none() && !same_creation_request(existing, &input) { - return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑")); - } - if input - .expected_revision - .is_some_and(|revision| revision != existing.revision.get()) - { - return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试")); - } - } - self.configure_from_snapshot(snapshot, existing, input, context) - .await - } - - pub(super) async fn configure_from_snapshot( - &self, - mut snapshot: PluginInstanceSnapshot, - existing: Option, - input: ConfigurePluginInstance, - context: &MutationContext, - ) -> Result { - let expected_revision = snapshot.config_revision; - let candidate_revision = next_revision(expected_revision)?; - let artifacts = self - .store - .list_artifacts() - .await - .map_err(|error| map_store_error(error, "plugin"))?; - let artifact = artifacts - .iter() - .find(|artifact| artifact.metadata.sha256 == input.artifact_sha256) - .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; - if artifact.accepted_at.is_none() { - return Err(AdminError::invalid("请先安装并接受该版本声明的权限")); - } - if input.enabled - && let Some(warning) = self - .package_status(&input.artifact_sha256) - .await? - .load_error - { - return Err(AdminError::invalid(format!( - "{warning},无法启动,请安装兼容版本" - ))); - } - let same_plugin = |digest: &str| { - artifacts.iter().any(|item| { - item.metadata.sha256 == digest - && item.metadata.plugin_id == artifact.metadata.plugin_id - }) - }; - if existing - .as_ref() - .is_some_and(|previous| !same_plugin(&previous.artifact_sha256)) - { - return Err(AdminError::invalid("不能将已有配置切换为另一个插件")); - } - if input.enabled - && snapshot.instances.iter().any(|other| { - other.enabled - && existing - .as_ref() - .is_none_or(|current| current.id != other.id) - && same_plugin(&other.artifact_sha256) - && !input - .replace_instances - .iter() - .any(|replacement| replacement.id == other.id) - }) - { - return Err(AdminError::conflict( - "此插件已有启用配置,请先切换或停用旧配置", - )); - } - let saved = if let Some(previous) = &existing - && previous.artifact_sha256 != input.artifact_sha256 - && input.secrets.is_none() - { - self.store - .load_version_configuration(&previous.id, &input.artifact_sha256) - .await - .map_err(|error| map_store_error(error, "plugin"))? - } else { - None - }; - let instance = PluginInstance { - id: existing.as_ref().map_or_else( - || { - input - .creation_id - .clone() - .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()) - }, - |instance| instance.id.clone(), - ), - name: input.name.trim().to_owned(), - artifact_sha256: input.artifact_sha256, - enabled: input.enabled, - trusted_process: true, - configuration: input.configuration, - secrets: input - .secrets - .or_else(|| saved.map(|saved| saved.secrets)) - .unwrap_or_else(|| { - existing - .as_ref() - .map_or_else(Default::default, |instance| instance.secrets.clone()) - }), - bindings: input.bindings, - revision: candidate_revision, - }; - validate_input(&instance)?; - if existing - .as_ref() - .is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256) - && !self - .preparation - .configuration_ready(instance.clone(), &artifact.metadata) - .await? - { - return Err(AdminError::invalid( - "目标版本仍缺少必填设置,请补充后再切换", - )); - } - - let replacements = input.replace_instances; - if !replacements.is_empty() { - if !instance.enabled || replacements.len() > 256 { - return Err(AdminError::invalid( - "仅启用配置时可以切换其他配置,单次最多 256 项", - )); - } - let mut ids = BTreeSet::new(); - for replacement in &replacements { - if replacement.id == instance.id || !ids.insert(&replacement.id) { - return Err(AdminError::invalid("停用列表不能重复或包含当前配置")); - } - let previous = snapshot - .instances - .iter() - .find(|item| item.id == replacement.id) - .ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?; - if !previous.enabled || previous.revision.get() != replacement.expected_revision { - return Err(AdminError::conflict("待停用配置已变更,请重新确认")); - } - if !artifacts.iter().any(|item| { - item.metadata.sha256 == previous.artifact_sha256 - && item.metadata.plugin_id == artifact.metadata.plugin_id - }) { - return Err(AdminError::invalid("只能切换同一插件的配置")); - } - } - } - let state = self.preparation.validate(instance.clone()).await?; - if let Some(existing) = &existing - && self.state.transition_required(&existing.id, &state).await? - { - return self - .configure_instance_with_state_transition( - snapshot, - existing.clone(), - instance, - state, - &replacements, - context, - ) - .await; - } - snapshot.instances.retain(|item| item.id != instance.id); - snapshot.instances.push(instance.clone()); - snapshot.config_revision = candidate_revision; - apply_replacements(&mut snapshot, &replacements); - let prepared = self.preparation.prepare(snapshot.clone()).await?; - let result = self - .store - .save_instance_replacing( - instance, - expected_revision, - PluginStateCommit { - configuration: state, - transition_id: None, - }, - &replacements, - context, - ) - .await - .map_err(|e| map_store_error(e, "plugin"))?; - self.publish_instance(snapshot, prepared, &result, &replacements) - .await?; - Ok(result) - } - - async fn configure_instance_with_state_transition( - &self, - mut snapshot: PluginInstanceSnapshot, - previous: PluginInstance, - mut target: PluginInstance, - target_state: PluginStateConfiguration, - replacements: &[PluginInstanceReplacement], - context: &MutationContext, - ) -> Result { - let previous_state = self.preparation.validate(previous.clone()).await?; - let original_revision = snapshot.config_revision; - // 即使最终保持停用,迁移也必须由目标制品的受控候选执行 - let mut migration_instance = target.clone(); - migration_instance.enabled = true; - snapshot.config_revision = migration_instance.revision; - snapshot - .instances - .retain(|instance| instance.id != migration_instance.id); - snapshot.instances.push(migration_instance); - apply_replacements(&mut snapshot, replacements); - let migration_prepared = self.preparation.prepare(snapshot).await?; - - let mut disabled_revision = None; - let disabled = if previous.enabled { - let mut disabled = previous.clone(); - disabled.enabled = false; - let mutation = self - .store - .save_instance_with_state( - disabled, - original_revision, - PluginStateCommit { - configuration: previous_state.clone(), - transition_id: None, - }, - context, - ) - .await - .map_err(|error| map_store_error(error, "plugin"))?; - publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; - self.preparation - .quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision) - .await; - disabled_revision = Some(mutation.config_revision); - mutation.instance - } else { - previous.clone() - }; - let expected_revision = disabled_revision.unwrap_or(original_revision); - let transition = match self - .state - .begin_transition( - &disabled.id, - disabled.revision, - &target.artifact_sha256, - target_state.clone(), - ) - .await - { - Ok(transition) => transition, - Err(error) => { - return Err(self - .recover_failed_state_transition( - None, - &previous, - &previous_state, - disabled_revision, - context, - error, - ) - .await); - } - }; - // 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置 - let committed = async { - self.preparation - .migrate_state(&migration_prepared, transition.clone()) - .await?; - let mut current = self - .store - .load_instances() - .await - .map_err(|error| map_store_error(error, "plugin"))?; - if current.config_revision != expected_revision { - return Err(AdminError::conflict("迁移期间插件配置已发生变化")); - } - let candidate_revision = next_revision(expected_revision)?; - target.revision = candidate_revision; - current.instances.retain(|item| item.id != target.id); - current.instances.push(target.clone()); - current.config_revision = candidate_revision; - apply_replacements(&mut current, replacements); - let prepared = self.preparation.prepare(current.clone()).await?; - let result = self - .store - .save_instance_replacing( - target, - expected_revision, - PluginStateCommit { - configuration: target_state, - transition_id: Some(transition.id.clone()), - }, - replacements, - context, - ) - .await - .map_err(|error| map_store_error(error, "plugin"))?; - Ok((current, prepared, result)) - } - .await; - let (current, prepared, result) = match committed { - Ok(committed) => committed, - Err(error) => { - return Err(self - .recover_failed_state_transition( - Some(&transition.id), - &previous, - &previous_state, - disabled_revision, - context, - error, - ) - .await); - } - }; - self.publish_instance(current, prepared, &result, replacements) - .await?; - drop(migration_prepared); - Ok(result) - } - - async fn recover_failed_state_transition( - &self, - transition_id: Option<&str>, - previous: &PluginInstance, - previous_state: &PluginStateConfiguration, - disabled_revision: Option, - context: &MutationContext, - original_error: AdminError, - ) -> AdminError { - if let Some(transition_id) = transition_id { - self.state.abort_transition(transition_id).await; - } - let Some(disabled_revision) = disabled_revision else { - return original_error; - }; - let restored = async { - let mut snapshot = self - .store - .load_instances() - .await - .map_err(|error| map_store_error(error, "plugin"))?; - if snapshot.config_revision != disabled_revision { - return Err(AdminError::conflict( - "状态迁移失败;实例已保持停用,且配置已被其他操作修改", - )); - } - let candidate_revision = next_revision(disabled_revision)?; - let mut restore = previous.clone(); - restore.enabled = true; - restore.revision = candidate_revision; - snapshot.instances.retain(|item| item.id != restore.id); - snapshot.instances.push(restore.clone()); - snapshot.config_revision = candidate_revision; - let prepared = self.preparation.prepare(snapshot.clone()).await?; - let mutation = self - .store - .save_instance_with_state( - restore, - disabled_revision, - PluginStateCommit { - configuration: previous_state.clone(), - transition_id: None, - }, - context, - ) - .await - .map_err(|error| map_store_error(error, "plugin"))?; - self.publish_instance(snapshot, prepared, &mutation, &[]) - .await?; - Ok::<(), AdminError>(()) - } - .await; - match restored { - Ok(()) => original_error, - Err(error) => { - tracing::warn!( - error_kind = ?error.kind(), - "plugin state migration failed and the previous instance could not be restored" - ); - AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用") - } - } - } - - /// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段 - async fn publish_instance( - &self, - mut snapshot: PluginInstanceSnapshot, - prepared: ExtensionSetReference, - mutation: &PluginInstanceMutation, - replacements: &[PluginInstanceReplacement], - ) -> Result<(), AdminError> { - let prepared = if mutation.config_revision == snapshot.config_revision - && mutation.instance.revision == snapshot.config_revision - { - prepared - } else { - snapshot.config_revision = mutation.config_revision; - snapshot - .instances - .retain(|item| item.id != mutation.instance.id); - snapshot.instances.push(mutation.instance.clone()); - apply_replacements(&mut snapshot, replacements); - self.preparation.prepare(snapshot).await? - }; - self.preparation - .activate_state(&prepared, &mutation.instance) - .await?; - publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; - drop(prepared); - Ok(()) - } - - pub async fn delete_instance( - &self, - id: &str, - context: &MutationContext, - ) -> Result { - let snapshot = self - .store - .load_instances() - .await - .map_err(|e| map_store_error(e, "plugin"))?; - let instance = snapshot - .instances - .iter() - .find(|instance| instance.id == id) - .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; - if instance.enabled { - return Err(AdminError::conflict("请先停用插件实例")); - } - let revision = self - .store - .delete_instance(id, snapshot.config_revision, context) - .await - .map_err(|e| map_store_error(e, "plugin"))?; - publish_committed(self.snapshots.as_ref(), revision).await?; - Ok(revision) - } - - /// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同 - pub async fn disable_instance( - &self, - id: &str, - context: &MutationContext, - ) -> Result { - let PluginInstanceSnapshot { - config_revision, - instances, - } = self - .store - .load_instances() - .await - .map_err(|e| map_store_error(e, "plugin"))?; - let mut instance = instances - .into_iter() - .find(|instance| instance.id == id) - .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; - instance.enabled = false; - let result = self - .store - .save_instance(instance, config_revision, context) - .await - .map_err(|e| map_store_error(e, "plugin"))?; - publish_committed(self.snapshots.as_ref(), result.config_revision).await?; - Ok(result) - } -} - -fn apply_replacements( - snapshot: &mut PluginInstanceSnapshot, - replacements: &[PluginInstanceReplacement], -) { - for instance in &mut snapshot.instances { - if replacements.iter().any(|item| item.id == instance.id) { - instance.enabled = false; - instance.revision = snapshot.config_revision; - } - } -} - -fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool { - let same_secrets = input - .secrets - .as_ref() - .map_or(existing.secrets.is_empty(), |secrets| { - secrets.len() == existing.secrets.len() - && secrets.iter().all(|(key, value)| { - existing - .secrets - .get(key) - .is_some_and(|current| current.expose_secret() == value.expose_secret()) - }) - }); - existing.name == input.name.trim() - && existing.artifact_sha256 == input.artifact_sha256 - && existing.enabled == input.enabled - && existing.configuration == input.configuration - && existing.bindings == input.bindings - && same_secrets -} - -fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> { - if instance.name.is_empty() - || instance.name.len() > 128 - || instance.name.chars().any(char::is_control) - || instance.artifact_sha256.len() != 64 - || !instance - .artifact_sha256 - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - || !instance.configuration.is_object() - || serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024) - || instance.secrets.len() > 64 - || instance.bindings.len() > 64 - { - return Err(AdminError::invalid("插件实例配置不合法或超过大小限制")); - } - use secrecy::ExposeSecret as _; - if instance - .secrets - .iter() - .any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192) - || instance - .secrets - .values() - .map(|value| value.expose_secret().len()) - .sum::() - > 48 * 1024 - { - return Err(AdminError::invalid("插件敏感配置超过大小限制")); - } - if instance.bindings.iter().any(|binding| { - binding.contribution.is_empty() - || binding.contribution.len() > 128 - || !binding - .contribution - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) - || binding.client_key_ids.len() > 256 - || binding.account_group_ids.len() > 256 - || binding.client_key_ids.iter().collect::>().len() - != binding.client_key_ids.len() - || binding - .account_group_ids - .iter() - .collect::>() - .len() - != binding.account_group_ids.len() - || binding - .client_key_ids - .iter() - .any(|id| ClientApiKeyId::new(id.clone()).is_err()) - || binding - .account_group_ids - .iter() - .any(|id| AccountGroupId::new(id.clone()).is_err()) - || binding.identity_bindings.len() > 256 - || binding - .identity_bindings - .iter() - .map(|identity| &identity.principal) - .collect::>() - .len() - != binding.identity_bindings.len() - || binding.identity_bindings.iter().any(|identity| { - identity.principal.trim() != identity.principal - || identity.principal.is_empty() - || identity.principal.len() > 256 - || identity.principal.chars().any(char::is_control) - || ClientApiKeyId::new(identity.client_key_id.clone()).is_err() - }) - }) { - return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法")); - } - Ok(()) -} - -fn next_revision(revision: Revision) -> Result { - Revision::new( - revision - .get() - .checked_add(1) - .ok_or_else(|| AdminError::internal("配置版本已耗尽"))?, - ) - .map_err(|_| AdminError::internal("配置版本不合法")) -} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/configuration.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/configuration.rs new file mode 100644 index 000000000..d7908a9c6 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/configuration.rs @@ -0,0 +1,135 @@ +//! 插件配置的输入约束、创建幂等比较与候选快照变换 + +use std::collections::BTreeSet; + +use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId}; +use secrecy::ExposeSecret; + +use crate::model::{ + AdminError, Revision, + plugins::instances::{ + ConfigurePluginInstance, PluginInstance, PluginInstanceReplacement, PluginInstanceSnapshot, + }, +}; + +pub(super) fn apply_replacements( + snapshot: &mut PluginInstanceSnapshot, + replacements: &[PluginInstanceReplacement], +) { + for instance in &mut snapshot.instances { + if replacements.iter().any(|item| item.id == instance.id) { + instance.enabled = false; + instance.revision = snapshot.config_revision; + } + } +} + +pub(super) fn same_creation_request( + existing: &PluginInstance, + input: &ConfigurePluginInstance, +) -> bool { + let same_secrets = input + .secrets + .as_ref() + .map_or(existing.secrets.is_empty(), |secrets| { + secrets.len() == existing.secrets.len() + && secrets.iter().all(|(key, value)| { + existing + .secrets + .get(key) + .is_some_and(|current| current.expose_secret() == value.expose_secret()) + }) + }); + existing.name == input.name.trim() + && existing.artifact_sha256 == input.artifact_sha256 + && existing.enabled == input.enabled + && existing.configuration == input.configuration + && existing.bindings == input.bindings + && same_secrets +} + +pub(super) fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> { + if instance.name.is_empty() + || instance.name.len() > 128 + || instance.name.chars().any(char::is_control) + || instance.artifact_sha256.len() != 64 + || !instance + .artifact_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !instance.configuration.is_object() + || serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024) + || instance.secrets.len() > 64 + || instance.bindings.len() > 64 + { + return Err(AdminError::invalid("插件实例配置不合法或超过大小限制")); + } + use secrecy::ExposeSecret as _; + if instance + .secrets + .iter() + .any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192) + || instance + .secrets + .values() + .map(|value| value.expose_secret().len()) + .sum::() + > 48 * 1024 + { + return Err(AdminError::invalid("插件敏感配置超过大小限制")); + } + if instance.bindings.iter().any(|binding| { + binding.contribution.is_empty() + || binding.contribution.len() > 128 + || !binding + .contribution + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + || binding.client_key_ids.len() > 256 + || binding.account_group_ids.len() > 256 + || binding.client_key_ids.iter().collect::>().len() + != binding.client_key_ids.len() + || binding + .account_group_ids + .iter() + .collect::>() + .len() + != binding.account_group_ids.len() + || binding + .client_key_ids + .iter() + .any(|id| ClientApiKeyId::new(id.clone()).is_err()) + || binding + .account_group_ids + .iter() + .any(|id| AccountGroupId::new(id.clone()).is_err()) + || binding.identity_bindings.len() > 256 + || binding + .identity_bindings + .iter() + .map(|identity| &identity.principal) + .collect::>() + .len() + != binding.identity_bindings.len() + || binding.identity_bindings.iter().any(|identity| { + identity.principal.trim() != identity.principal + || identity.principal.is_empty() + || identity.principal.len() > 256 + || identity.principal.chars().any(char::is_control) + || ClientApiKeyId::new(identity.client_key_id.clone()).is_err() + }) + }) { + return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法")); + } + Ok(()) +} + +pub(super) fn next_revision(revision: Revision) -> Result { + Revision::new( + revision + .get() + .checked_add(1) + .ok_or_else(|| AdminError::internal("配置版本已耗尽"))?, + ) + .map_err(|_| AdminError::internal("配置版本不合法")) +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs new file mode 100644 index 000000000..c4584e83a --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/migration.rs @@ -0,0 +1,216 @@ +//! 插件私有状态迁移的排空、暂存提交与失败恢复编排 + +use super::{ + PluginsService, + configuration::{apply_replacements, next_revision}, +}; +use crate::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + instances::{ + PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, + PluginInstanceSnapshot, + }, + state::{PluginStateCommit, PluginStateConfiguration}, + }, + }, + use_case::{map_store_error, publish_committed}, +}; + +impl PluginsService { + pub(super) async fn configure_instance_with_state_transition( + &self, + mut snapshot: PluginInstanceSnapshot, + previous: PluginInstance, + mut target: PluginInstance, + target_state: PluginStateConfiguration, + replacements: &[PluginInstanceReplacement], + context: &MutationContext, + ) -> Result { + let previous_state = self.preparation.validate(previous.clone()).await?; + let original_revision = snapshot.config_revision; + // 即使最终保持停用,迁移也必须由目标制品的受控候选执行 + let mut migration_instance = target.clone(); + migration_instance.enabled = true; + snapshot.config_revision = migration_instance.revision; + snapshot + .instances + .retain(|instance| instance.id != migration_instance.id); + snapshot.instances.push(migration_instance); + apply_replacements(&mut snapshot, replacements); + let migration_prepared = self.preparation.prepare(snapshot).await?; + + let mut disabled_revision = None; + let disabled = if previous.enabled { + let mut disabled = previous.clone(); + disabled.enabled = false; + let mutation = self + .store + .save_instance_with_state( + disabled, + original_revision, + PluginStateCommit { + configuration: previous_state.clone(), + transition_id: None, + }, + context, + ) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; + self.preparation + .quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision) + .await; + disabled_revision = Some(mutation.config_revision); + mutation.instance + } else { + previous.clone() + }; + let expected_revision = disabled_revision.unwrap_or(original_revision); + let transition = match self + .state + .begin_transition( + &disabled.id, + disabled.revision, + &target.artifact_sha256, + target_state.clone(), + ) + .await + { + Ok(transition) => transition, + Err(error) => { + return Err(self + .recover_failed_state_transition( + None, + &previous, + &previous_state, + disabled_revision, + context, + error, + ) + .await); + } + }; + // 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置 + let committed = async { + self.preparation + .migrate_state(&migration_prepared, transition.clone()) + .await?; + let mut current = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + if current.config_revision != expected_revision { + return Err(AdminError::conflict("迁移期间插件配置已发生变化")); + } + let candidate_revision = next_revision(expected_revision)?; + target.revision = candidate_revision; + current.instances.retain(|item| item.id != target.id); + current.instances.push(target.clone()); + current.config_revision = candidate_revision; + apply_replacements(&mut current, replacements); + let prepared = self.preparation.prepare(current.clone()).await?; + let result = self + .store + .save_instance_replacing( + target, + expected_revision, + PluginStateCommit { + configuration: target_state, + transition_id: Some(transition.id.clone()), + }, + replacements, + context, + ) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + Ok((current, prepared, result)) + } + .await; + let (current, prepared, result) = match committed { + Ok(committed) => committed, + Err(error) => { + return Err(self + .recover_failed_state_transition( + Some(&transition.id), + &previous, + &previous_state, + disabled_revision, + context, + error, + ) + .await); + } + }; + self.publish_instance(current, prepared, &result, replacements) + .await?; + drop(migration_prepared); + Ok(result) + } + + async fn recover_failed_state_transition( + &self, + transition_id: Option<&str>, + previous: &PluginInstance, + previous_state: &PluginStateConfiguration, + disabled_revision: Option, + context: &MutationContext, + original_error: AdminError, + ) -> AdminError { + if let Some(transition_id) = transition_id { + self.state.abort_transition(transition_id).await; + } + let Some(disabled_revision) = disabled_revision else { + return original_error; + }; + let restored = async { + let mut snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + if snapshot.config_revision != disabled_revision { + return Err(AdminError::conflict( + "状态迁移失败;实例已保持停用,且配置已被其他操作修改", + )); + } + let candidate_revision = next_revision(disabled_revision)?; + let mut restore = previous.clone(); + restore.enabled = true; + restore.revision = candidate_revision; + snapshot.instances.retain(|item| item.id != restore.id); + snapshot.instances.push(restore.clone()); + snapshot.config_revision = candidate_revision; + let prepared = self.preparation.prepare(snapshot.clone()).await?; + let mutation = self + .store + .save_instance_with_state( + restore, + disabled_revision, + PluginStateCommit { + configuration: previous_state.clone(), + transition_id: None, + }, + context, + ) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + self.publish_instance(snapshot, prepared, &mutation, &[]) + .await?; + Ok::<(), AdminError>(()) + } + .await; + match restored { + Ok(()) => original_error, + Err(error) => { + tracing::warn!( + error_kind = ?error.kind(), + "plugin state migration failed and the previous instance could not be restored" + ); + AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用") + } + } + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/mod.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/mod.rs new file mode 100644 index 000000000..53796cd16 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/mod.rs @@ -0,0 +1,15 @@ +//! 插件实例用例的职责边界与静态检查结果 + +mod configuration; +mod migration; +mod mutations; +mod publication; +mod queries; + +use super::PluginsService; + +#[derive(Clone, Default)] +pub(super) struct PackageStatus { + pub(super) warning: Option, + pub(super) load_error: Option, +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs new file mode 100644 index 000000000..1a6a46b21 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/mutations.rs @@ -0,0 +1,312 @@ +//! 插件实例的配置提交、发布、停用与删除用例 + +use std::collections::BTreeSet; + +use super::{ + PluginsService, + configuration::{apply_replacements, next_revision, same_creation_request, validate_input}, +}; +use crate::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + instances::{ + ConfigurePluginInstance, PluginInstance, PluginInstanceMutation, + PluginInstanceSnapshot, + }, + state::PluginStateCommit, + }, + }, + use_case::{map_store_error, publish_committed}, +}; + +impl PluginsService { + pub async fn configure_instance( + &self, + id: Option<&str>, + input: ConfigurePluginInstance, + context: &MutationContext, + ) -> Result { + if id.is_some() && input.creation_id.is_some() { + return Err(AdminError::invalid("编辑配置不能使用创建标识")); + } + if id.is_none() && input.expected_revision.is_some() { + return Err(AdminError::invalid("创建配置不能指定已有配置版本")); + } + if let Some(creation_id) = &input.creation_id { + let parsed = uuid::Uuid::parse_str(creation_id) + .map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?; + if parsed.to_string() != *creation_id { + return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式")); + } + } + let snapshot = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let existing = match id { + Some(id) => Some( + snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .cloned() + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?, + ), + None => input.creation_id.as_ref().and_then(|creation_id| { + snapshot + .instances + .iter() + .find(|instance| instance.id == *creation_id) + .cloned() + }), + }; + if let Some(existing) = &existing { + if id.is_none() && !same_creation_request(existing, &input) { + return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑")); + } + if input + .expected_revision + .is_some_and(|revision| revision != existing.revision.get()) + { + return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试")); + } + } + self.configure_from_snapshot(snapshot, existing, input, context) + .await + } + + pub(in crate::use_case::plugins) async fn configure_from_snapshot( + &self, + mut snapshot: PluginInstanceSnapshot, + existing: Option, + input: ConfigurePluginInstance, + context: &MutationContext, + ) -> Result { + let expected_revision = snapshot.config_revision; + let candidate_revision = next_revision(expected_revision)?; + let artifacts = self + .store + .list_artifacts() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let artifact = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == input.artifact_sha256) + .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; + if artifact.accepted_at.is_none() { + return Err(AdminError::invalid("请先安装并确认完整信任该版本")); + } + if input.enabled + && let Some(warning) = self + .package_status(&input.artifact_sha256) + .await? + .load_error + { + return Err(AdminError::invalid(format!( + "{warning},无法启动,请安装兼容版本" + ))); + } + let same_plugin = |digest: &str| { + artifacts.iter().any(|item| { + item.metadata.sha256 == digest + && item.metadata.plugin_id == artifact.metadata.plugin_id + }) + }; + if existing + .as_ref() + .is_some_and(|previous| !same_plugin(&previous.artifact_sha256)) + { + return Err(AdminError::invalid("不能将已有配置切换为另一个插件")); + } + if input.enabled + && snapshot.instances.iter().any(|other| { + other.enabled + && existing + .as_ref() + .is_none_or(|current| current.id != other.id) + && same_plugin(&other.artifact_sha256) + && !input + .replace_instances + .iter() + .any(|replacement| replacement.id == other.id) + }) + { + return Err(AdminError::conflict( + "此插件已有启用配置,请先切换或停用旧配置", + )); + } + let saved = if let Some(previous) = &existing + && previous.artifact_sha256 != input.artifact_sha256 + && input.secrets.is_none() + { + self.store + .load_version_configuration(&previous.id, &input.artifact_sha256) + .await + .map_err(|error| map_store_error(error, "plugin"))? + } else { + None + }; + let instance = PluginInstance { + id: existing.as_ref().map_or_else( + || { + input + .creation_id + .clone() + .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()) + }, + |instance| instance.id.clone(), + ), + name: input.name.trim().to_owned(), + artifact_sha256: input.artifact_sha256, + enabled: input.enabled, + trusted_process: true, + configuration: input.configuration, + secrets: input + .secrets + .or_else(|| saved.map(|saved| saved.secrets)) + .unwrap_or_else(|| { + existing + .as_ref() + .map_or_else(Default::default, |instance| instance.secrets.clone()) + }), + bindings: input.bindings, + revision: candidate_revision, + }; + validate_input(&instance)?; + if existing + .as_ref() + .is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256) + && !self + .preparation + .configuration_ready(instance.clone(), &artifact.metadata) + .await? + { + return Err(AdminError::invalid( + "目标版本仍缺少必填设置,请补充后再切换", + )); + } + + let replacements = input.replace_instances; + if !replacements.is_empty() { + if !instance.enabled || replacements.len() > 256 { + return Err(AdminError::invalid( + "仅启用配置时可以切换其他配置,单次最多 256 项", + )); + } + let mut ids = BTreeSet::new(); + for replacement in &replacements { + if replacement.id == instance.id || !ids.insert(&replacement.id) { + return Err(AdminError::invalid("停用列表不能重复或包含当前配置")); + } + let previous = snapshot + .instances + .iter() + .find(|item| item.id == replacement.id) + .ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?; + if !previous.enabled || previous.revision.get() != replacement.expected_revision { + return Err(AdminError::conflict("待停用配置已变更,请重新确认")); + } + if !artifacts.iter().any(|item| { + item.metadata.sha256 == previous.artifact_sha256 + && item.metadata.plugin_id == artifact.metadata.plugin_id + }) { + return Err(AdminError::invalid("只能切换同一插件的配置")); + } + } + } + let state = self.preparation.validate(instance.clone()).await?; + if let Some(existing) = &existing + && self.state.transition_required(&existing.id, &state).await? + { + return self + .configure_instance_with_state_transition( + snapshot, + existing.clone(), + instance, + state, + &replacements, + context, + ) + .await; + } + snapshot.instances.retain(|item| item.id != instance.id); + snapshot.instances.push(instance.clone()); + snapshot.config_revision = candidate_revision; + apply_replacements(&mut snapshot, &replacements); + let prepared = self.preparation.prepare(snapshot.clone()).await?; + let result = self + .store + .save_instance_replacing( + instance, + expected_revision, + PluginStateCommit { + configuration: state, + transition_id: None, + }, + &replacements, + context, + ) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + self.publish_instance(snapshot, prepared, &result, &replacements) + .await?; + Ok(result) + } + + pub async fn delete_instance( + &self, + id: &str, + context: &MutationContext, + ) -> Result { + let snapshot = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let instance = snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + if instance.enabled { + return Err(AdminError::conflict("请先停用插件实例")); + } + let revision = self + .store + .delete_instance(id, snapshot.config_revision, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), revision).await?; + Ok(revision) + } + + /// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同 + pub async fn disable_instance( + &self, + id: &str, + context: &MutationContext, + ) -> Result { + let PluginInstanceSnapshot { + config_revision, + instances, + } = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let mut instance = instances + .into_iter() + .find(|instance| instance.id == id) + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + instance.enabled = false; + let result = self + .store + .save_instance(instance, config_revision, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), result.config_revision).await?; + Ok(result) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs new file mode 100644 index 000000000..bd1393616 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/publication.rs @@ -0,0 +1,45 @@ +//! 配置提交与迁移恢复共用的状态激活和发布收尾 + +use gateway_core::routing::extensions::ExtensionSetReference; + +use super::{PluginsService, configuration::apply_replacements}; +use crate::{ + model::{ + AdminError, + plugins::instances::{ + PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceSnapshot, + }, + }, + use_case::publish_committed, +}; + +impl PluginsService { + /// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段 + pub(super) async fn publish_instance( + &self, + mut snapshot: PluginInstanceSnapshot, + prepared: ExtensionSetReference, + mutation: &PluginInstanceMutation, + replacements: &[PluginInstanceReplacement], + ) -> Result<(), AdminError> { + let prepared = if mutation.config_revision == snapshot.config_revision + && mutation.instance.revision == snapshot.config_revision + { + prepared + } else { + snapshot.config_revision = mutation.config_revision; + snapshot + .instances + .retain(|item| item.id != mutation.instance.id); + snapshot.instances.push(mutation.instance.clone()); + apply_replacements(&mut snapshot, replacements); + self.preparation.prepare(snapshot).await? + }; + self.preparation + .activate_state(&prepared, &mutation.instance) + .await?; + publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; + drop(prepared); + Ok(()) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances/queries.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances/queries.rs new file mode 100644 index 000000000..03e0fdd78 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances/queries.rs @@ -0,0 +1,122 @@ +//! 插件实例列表与静态包检查的只读投影,不从发布事实推断进程状态 + +use std::collections::BTreeMap; + +use super::{PackageStatus, PluginsService}; +use crate::{ + model::{ + AdminError, AdminErrorKind, + plugins::instances::{ + PluginInstanceRuntime, PluginInstanceRuntimeStatus, PluginInstanceView, + }, + }, + use_case::map_store_error, +}; + +impl PluginsService { + /// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误 + pub(in crate::use_case::plugins) async fn package_status( + &self, + digest: &str, + ) -> Result { + // 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包 + // 只缓存有限数量的确定结果,暂时性错误仍可重试 + if let Some(status) = self.compatibility.lock().await.get(digest).cloned() { + return Ok(status); + } + let artifact = self + .store + .load_artifact(digest) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let status = match self + .inspector + .compatibility_warning(artifact.archive, digest.to_owned()) + .await + { + Ok(warning) => PackageStatus { + warning, + load_error: None, + }, + Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus { + warning: None, + load_error: Some(error.message().to_owned()), + }, + Err(error) => return Err(error), + }; + let mut cache = self.compatibility.lock().await; + if cache.len() < 128 { + cache.insert(digest.to_owned(), status.clone()); + } + Ok(status) + } + + pub async fn instances(&self) -> Result, AdminError> { + let snapshot = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let published = self.published.snapshot_for_diagnostics(); + let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get()); + let mut diagnostics = self + .preparation + .runtime_diagnostics( + &snapshot, + published_revision, + published + .as_ref() + .and_then(|snapshot| snapshot.extensions()), + ) + .await + .unwrap_or_default(); + let artifacts = self + .list() + .await? + .into_iter() + .map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata)) + .collect::>(); + let mut views = Vec::with_capacity(snapshot.instances.len()); + for instance in snapshot.instances { + let metadata = artifacts + .get(&instance.artifact_sha256) + .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; + let package_status = self.package_status(&instance.artifact_sha256).await?; + let configuration_required = package_status.load_error.is_none() + && !self + .preparation + .configuration_ready(instance.clone(), metadata) + .await?; + let runtime = + diagnostics + .remove(&instance.id) + .unwrap_or_else(|| PluginInstanceRuntime { + status: if !instance.enabled { + PluginInstanceRuntimeStatus::Disabled + } else { + PluginInstanceRuntimeStatus::Unknown + }, + actual_revision: None, + actual_artifact_sha256: None, + failure: None, + draining_revisions: Vec::new(), + load: None, + }); + views.push(PluginInstanceView { + api_deprecations: if package_status.load_error.is_none() { + self.inspector.api_deprecations(metadata)? + } else { + Vec::new() + }, + configuration_required, + compatibility_warning: package_status.warning, + load_error: package_status.load_error, + running: runtime.status == PluginInstanceRuntimeStatus::Running, + published_revision, + runtime, + instance, + }); + } + Ok(views) + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs similarity index 97% rename from backend/crates/gateway-admin/tests/use_case/plugins/instances.rs rename to backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs index bec8de7fd..7ecebacdd 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances/mod.rs @@ -49,6 +49,8 @@ use serde_json::json; use super::TestPluginPorts; +mod queries; + const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const NEW_ARTIFACT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; @@ -88,6 +90,11 @@ impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture archive: Arc<[u8]>, digest: String, ) -> Result, AdminError> { + let gate = self.inspection_gate.lock().unwrap().clone(); + if let Some(gate) = gate.filter(|gate| gate.digest == digest) { + gate.started.notify_one(); + gate.release.notified().await; + } self.inspect(archive, Some(digest)).await?; Ok(self.data.lock().unwrap().compatibility_warning.clone()) } @@ -143,11 +150,19 @@ struct LifecycleFixture { behavior: MigrationBehavior, data: Mutex, next_prepared: AtomicU64, + inspection_gate: Mutex>>, +} + +struct InspectionGate { + digest: String, + started: tokio::sync::Notify, + release: tokio::sync::Notify, } impl LifecycleFixture { fn new(behavior: MigrationBehavior) -> Arc { Arc::new(Self { + inspection_gate: Mutex::new(None), behavior, data: Mutex::new(FixtureData { snapshot: PluginInstanceSnapshot { @@ -970,49 +985,6 @@ fn state_unavailable() -> PluginStateStoreResult { )) } -#[tokio::test] -async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() { - let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); - let expected = PluginInstanceRuntime { - status: PluginInstanceRuntimeStatus::PreparationFailed, - actual_revision: Some(9), - actual_artifact_sha256: Some(OLD_ARTIFACT.into()), - failure: Some(PluginInstanceRuntimeFailure { - code: "unavailable".into(), - message: "插件候选准备失败".into(), - }), - draining_revisions: vec![8], - }; - fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([( - "00000000-0000-7000-8000-000000000001".into(), - expected.clone(), - )])); - - let views = service(fixture, Arc::new(Published::default())) - .instances() - .await - .unwrap(); - assert_eq!(views.len(), 1); - assert!(!views[0].running); - assert_eq!(views[0].runtime, expected); -} - -#[tokio::test] -async fn instance_list_falls_back_without_inventing_runtime_failures() { - let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); - let views = service(fixture, Arc::new(Published::default())) - .instances() - .await - .unwrap(); - assert_eq!(views.len(), 1); - assert_eq!( - views[0].runtime.status, - PluginInstanceRuntimeStatus::AwaitingPublication - ); - assert!(!views[0].running); - assert!(views[0].runtime.failure.is_none()); -} - #[tokio::test] async fn incompatible_state_upgrade_quiesces_migrates_and_promotes_the_target() { let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances/queries.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances/queries.rs new file mode 100644 index 000000000..d7e600800 --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances/queries.rs @@ -0,0 +1,151 @@ +//! 实例运行诊断与包检查缓存的查询回归测试 + +use super::*; +use gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad; + +#[tokio::test] +async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let expected = PluginInstanceRuntime { + status: PluginInstanceRuntimeStatus::PreparationFailed, + actual_revision: Some(9), + actual_artifact_sha256: Some(OLD_ARTIFACT.into()), + failure: Some(PluginInstanceRuntimeFailure { + code: "unavailable".into(), + message: "插件候选准备失败".into(), + }), + draining_revisions: vec![8], + load: Some(PluginInstanceRuntimeLoad { + active_calls: 3, + executing_calls: 1, + queued_calls: 1, + resuming_calls: 1, + pending_callbacks: 2, + active_streams: 0, + }), + }; + fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([( + "00000000-0000-7000-8000-000000000001".into(), + expected.clone(), + )])); + + let views = service(fixture, Arc::new(Published::default())) + .instances() + .await + .unwrap(); + assert_eq!(views.len(), 1); + assert!(!views[0].running); + assert_eq!(views[0].runtime, expected); +} + +#[tokio::test] +async fn instance_list_falls_back_without_inventing_runtime_failures() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let views = service(fixture, Arc::new(Published::default())) + .instances() + .await + .unwrap(); + assert_eq!(views.len(), 1); + assert_eq!( + views[0].runtime.status, + PluginInstanceRuntimeStatus::Unknown + ); + assert!(!views[0].running); + assert!(views[0].runtime.failure.is_none()); + assert!(views[0].runtime.actual_revision.is_none()); + assert!(views[0].runtime.load.is_none()); +} + +#[tokio::test] +async fn disabled_instance_without_runtime_diagnostics_has_a_known_disabled_status() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().snapshot.instances[0].enabled = false; + let views = service(fixture, Arc::new(Published::default())) + .instances() + .await + .unwrap(); + assert_eq!( + views[0].runtime.status, + PluginInstanceRuntimeStatus::Disabled + ); + assert!(!views[0].running); + assert!(views[0].runtime.failure.is_none()); + assert!(views[0].runtime.load.is_none()); +} + +#[tokio::test] +async fn matching_publication_without_process_diagnostics_does_not_imply_running() { + for diagnostics in [None, Some(BTreeMap::new())] { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().diagnostics = diagnostics; + let revision = fixture.snapshot().config_revision.get(); + let published = RuntimeSnapshotHandle::new( + gateway_core::routing::RuntimeSnapshot::new( + ConfigRevision::new(revision).unwrap(), + gateway_core::settings::SettingsValues::new( + 1, + "round_robin", + BTreeMap::new(), + None, + None, + ), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .unwrap(), + ); + let service = PluginsService::new( + fixture.clone(), + fixture.clone(), + PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)), + Arc::new(Published::default()), + fixture.clone(), + published, + fixture, + ); + let view = service.instances().await.unwrap().remove(0); + assert_eq!(view.published_revision, Some(revision)); + assert_eq!(view.runtime.status, PluginInstanceRuntimeStatus::Unknown); + assert!(!view.running); + assert!(view.runtime.actual_revision.is_none()); + assert!(view.runtime.actual_artifact_sha256.is_none()); + assert!(view.runtime.failure.is_none()); + } +} + +#[tokio::test] +async fn cached_package_query_is_not_blocked_by_another_package_inspection() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let service = Arc::new(service(fixture.clone(), Arc::new(Published::default()))); + service.instances().await.unwrap(); + let gate = Arc::new(InspectionGate { + digest: NEW_ARTIFACT.into(), + started: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + *fixture.inspection_gate.lock().unwrap() = Some(gate.clone()); + fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = NEW_ARTIFACT.into(); + let inspecting = { + let service = service.clone(); + tokio::spawn(async move { service.instances().await }) + }; + tokio::time::timeout(std::time::Duration::from_secs(1), gate.started.notified()) + .await + .expect("another package inspection started"); + + fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = OLD_ARTIFACT.into(); + let cached = + tokio::time::timeout(std::time::Duration::from_millis(100), service.instances()).await; + gate.release.notify_one(); + inspecting.await.unwrap().unwrap(); + assert_eq!( + cached + .expect("cached query must not wait for inspection") + .unwrap()[0] + .instance + .artifact_sha256, + OLD_ARTIFACT + ); + assert_eq!(fixture.data.lock().unwrap().archive_reads, 2); +} diff --git a/backend/crates/gateway-api/src/admin/plugins/instances.rs b/backend/crates/gateway-api/src/admin/plugins/instances.rs index 5a782af8c..161ae790a 100644 --- a/backend/crates/gateway-api/src/admin/plugins/instances.rs +++ b/backend/crates/gateway-api/src/admin/plugins/instances.rs @@ -9,8 +9,8 @@ use axum::{ }; use gateway_admin::model::plugins::instances::{ ConfigurePluginInstance, PluginCapabilityBinding, PluginInstance, PluginInstanceRuntime, - PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, PluginInstanceView, - RollbackPluginInstance, + PluginInstanceRuntimeFailure, PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus, + PluginInstanceView, RollbackPluginInstance, }; use serde::{Deserialize, Serialize}; @@ -70,6 +70,7 @@ struct InstanceView { #[serde(rename_all = "snake_case")] enum InstanceRuntimeStatus { Disabled, + Unknown, AwaitingPublication, Preparing, Running, @@ -83,6 +84,7 @@ impl From for InstanceRuntimeStatus { fn from(value: PluginInstanceRuntimeStatus) -> Self { match value { PluginInstanceRuntimeStatus::Disabled => Self::Disabled, + PluginInstanceRuntimeStatus::Unknown => Self::Unknown, PluginInstanceRuntimeStatus::AwaitingPublication => Self::AwaitingPublication, PluginInstanceRuntimeStatus::Preparing => Self::Preparing, PluginInstanceRuntimeStatus::Running => Self::Running, @@ -118,6 +120,31 @@ struct InstanceRuntimeView { actual_artifact_sha256: Option, failure: Option, draining_revisions: Vec, + load: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InstanceRuntimeLoad { + active_calls: usize, + executing_calls: usize, + queued_calls: usize, + resuming_calls: usize, + pending_callbacks: usize, + active_streams: usize, +} + +impl From for InstanceRuntimeLoad { + fn from(value: PluginInstanceRuntimeLoad) -> Self { + Self { + active_calls: value.active_calls, + executing_calls: value.executing_calls, + queued_calls: value.queued_calls, + resuming_calls: value.resuming_calls, + pending_callbacks: value.pending_callbacks, + active_streams: value.active_streams, + } + } } impl From for InstanceRuntimeView { @@ -128,6 +155,7 @@ impl From for InstanceRuntimeView { actual_artifact_sha256: value.actual_artifact_sha256, failure: value.failure.map(Into::into), draining_revisions: value.draining_revisions, + load: value.load.map(Into::into), } } } diff --git a/backend/crates/gateway-api/tests/admin/plugins/mod.rs b/backend/crates/gateway-api/tests/admin/plugins/mod.rs index 054a3415b..16f000e5c 100644 --- a/backend/crates/gateway-api/tests/admin/plugins/mod.rs +++ b/backend/crates/gateway-api/tests/admin/plugins/mod.rs @@ -322,6 +322,17 @@ async fn instance_list_serializes_safe_runtime_diagnostics() { assert_eq!(instance["running"], false); assert_eq!(instance["runtime"]["status"], "preparation_failed"); assert_eq!(instance["runtime"]["actualRevision"], 6); + assert_eq!( + instance["runtime"]["load"], + serde_json::json!({ + "activeCalls": 3, + "executingCalls": 1, + "queuedCalls": 1, + "resumingCalls": 1, + "pendingCallbacks": 2, + "activeStreams": 0, + }) + ); assert_eq!( instance["runtime"]["drainingRevisions"], serde_json::json!([5]) @@ -665,6 +676,14 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts message: "插件候选准备失败".into(), }), draining_revisions: vec![5], + load: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad { + active_calls: 3, + executing_calls: 1, + queued_calls: 1, + resuming_calls: 1, + pending_callbacks: 2, + active_streams: 0, + }), }, ) }) diff --git a/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs b/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs index 46d8b6bfc..3814dbbcb 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/accounts.rs @@ -20,8 +20,8 @@ use gateway_core::{ use gateway_plugin_sdk::{ CallContext, ErrorCode, PluginFault, call::host::{ - AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult, AuthRuntimeAccount, - AuthSaveRequest, AuthSaveResult, CredentialFacts, + self as wire, AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult, + AuthRuntimeAccount, AuthSaveRequest, AuthSaveResult, CredentialFacts, }, }; @@ -105,7 +105,7 @@ impl PluginAccounts { } let access = self.slot.upgrade().map_err(map_admin_error)?; match method { - "host.auth.list" => { + wire::AUTH_LIST => { let request: AuthListRequest = decode(payload)?; let limit = PageSize::new(request.limit).map_err(|_| invalid())?; let cursor = request @@ -131,7 +131,7 @@ impl PluginAccounts { next_cursor: page.next_cursor.map(|cursor| cursor.as_str().to_owned()), }) } - "host.auth.get_runtime" => { + wire::AUTH_GET_RUNTIME => { let request: AuthGetRequest = decode(payload)?; let account_id = ProviderAccountId::new(request.account_id).map_err(|_| invalid())?; @@ -141,7 +141,7 @@ impl PluginAccounts { .map_err(map_admin_error)?; encode(&runtime_account(&account)) } - "host.auth.get" => { + wire::AUTH_GET => { let request: AuthGetRequest = decode(payload)?; let account_id = ProviderAccountId::new(request.account_id).map_err(|_| invalid())?; @@ -157,7 +157,7 @@ impl PluginAccounts { facts: credential_facts(account, credential.provider_material), }) } - "host.auth.save" => { + wire::AUTH_SAVE => { let request: AuthSaveRequest = decode(payload)?; encode(&self.save(context, scope, request).await?) } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/dispatch.rs b/backend/crates/gateway-plugin/runtime/src/callback/dispatch.rs new file mode 100644 index 000000000..c440d020d --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/dispatch.rs @@ -0,0 +1,245 @@ +//! 将有效父调用的宿主回调分派到所属原生能力,并随调用撤销资源 + +use std::sync::{Arc, Weak}; + +use futures::future::BoxFuture; +use gateway_plugin_sdk::{ + CallContext, ErrorCode, PluginFault, + call::{host, middleware as middleware_wire, upstream_adapter as upstream_wire}, + client::CallDeadline, +}; + +use super::{CallResources, CallbackScope, PluginCallbacks, denied, http, http_dispatch, upstream}; +use crate::{CallbackHandler, RpcReply}; + +impl CallbackHandler for PluginCallbacks { + fn begin(&self, context: &CallContext, deadline: &CallDeadline) { + let scope = self + .scopes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&context.resource_scope_id) + .and_then(Weak::upgrade) + .unwrap_or_else(|| { + let mut scope = CallbackScope::for_call(context); + if context.stage == gateway_plugin_sdk::Stage::Registration { + scope.request_body_limit = self.registration_body_limit; + } + Arc::new(scope) + }); + let middleware = self + .pending_middleware + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&context.resource_scope_id); + self.calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + context.call_id, + Arc::new(CallResources::new(context, deadline, scope, middleware)), + ); + } + + fn finish(&self, context: &CallContext) { + if let Some(call) = self + .calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&context.call_id) + { + call.close(); + } + } + + fn call( + &self, + context: CallContext, + method: String, + params: serde_json::Value, + payload: Vec, + ) -> BoxFuture<'static, Result> { + let call = self + .calls + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&context.call_id) + .cloned(); + let http = self.http.clone(); + let network = self.network.clone(); + let maximum_payload = self.maximum_payload; + let log = self.log.clone(); + let private_state = self.private_state.clone(); + let accounts = self.accounts.clone(); + let resources = self.resources.clone(); + let data = self.data.clone(); + let keys = self.keys.clone(); + let clients = self.clients.clone(); + let models = self.models.clone(); + let request_limits = self.request_limits.clone(); + let affinity = self.affinity.clone(); + let services = self.services.clone(); + let service_actor = self.service_actor.clone(); + Box::pin(async move { + let call = call.ok_or_else(denied)?; + let scope = &call.scope; + let (middleware, is_private_state) = { + let state = call + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.closed || call.deadline.is_elapsed() { + return Err(denied()); + } + if method == host::LOG { + return log.record(&context, params, &payload); + } + let middleware = if matches!( + method.as_str(), + middleware_wire::NEXT_METHOD + | middleware_wire::BODY_READ_METHOD + | middleware_wire::BODY_FACTS_METHOD + | middleware_wire::BODY_CLOSE_METHOD + | middleware_wire::http::UPGRADE_METHOD + | middleware_wire::http::RECEIVE_METHOD + | middleware_wire::websocket::SEND_METHOD + ) { + Some(state.middleware.clone().ok_or_else(denied)?) + } else { + None + }; + let is_private_state = matches!( + method.as_str(), + host::STATE_GET | host::STATE_PUT | host::STATE_DELETE + ); + (middleware, is_private_state) + }; + if matches!( + method.as_str(), + middleware_wire::http::DISPATCH_METHOD + | middleware_wire::http::BODY_READ_METHOD + | middleware_wire::http::BODY_CLOSE_METHOD + | middleware_wire::http::BODY_CREATE_METHOD + | middleware_wire::http::BODY_WRITE_METHOD + ) { + return tokio::select! { + biased; + () = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")), + result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result, + }; + } + if let Some(middleware) = middleware { + return middleware + .invoke(method, params, payload, maximum_payload) + .await; + } + if method == gateway_plugin_sdk::call::services::CALL_METHOD { + return services + .call(&context, &call, service_actor, params, &payload) + .await; + } + if is_private_state { + return private_state.call(&method, params, &payload).await; + } + if matches!( + method.as_str(), + gateway_plugin_sdk::call::resources::GROUP_ENSURE + | gateway_plugin_sdk::call::resources::GROUP_MEMBERS + | gateway_plugin_sdk::call::resources::KEY_ENSURE + ) { + return resources.call(&context, &method, params, &payload).await; + } + if matches!( + method.as_str(), + host::KEYS_LIST + | gateway_plugin_sdk::call::key_budgets::RESET + | gateway_plugin_sdk::call::key_budgets::GET + | gateway_plugin_sdk::call::key_budgets::UPDATE_LIMITS + ) { + return keys.call(&context, &method, params, &payload).await; + } + if matches!( + method.as_str(), + gateway_plugin_sdk::call::data::CLIENT_VERIFY + | gateway_plugin_sdk::call::data::ACCESS_CHECK + ) { + return clients + .call(&context, &call, &method, params, payload) + .await; + } + if method == host::MODELS_LIST { + return models.call(&context, &call, &method, params, payload).await; + } + if matches!( + method.as_str(), + host::MODEL_EXECUTE + | host::MODEL_EXECUTE_STREAM + | host::MODEL_STREAM_READ + | host::MODEL_STREAM_CLOSE + ) { + return models.call(&context, &call, &method, params, payload).await; + } + if method == host::AFFINITY_LOOKUP { + return affinity.call(&context, params, &payload).await; + } + let scope = Some(scope) + .filter(|scope| scope.authorizes(&context)) + .ok_or_else(denied)?; + if matches!( + method.as_str(), + gateway_plugin_sdk::call::data::ACCOUNTS_LIST + | gateway_plugin_sdk::call::data::KEYS_GET + | gateway_plugin_sdk::call::data::QUOTA_GET + | gateway_plugin_sdk::call::data::QUOTA_REFRESH + | gateway_plugin_sdk::call::data::QUOTA_DETAILS + ) { + return data.call(&method, params, &payload).await; + } + if matches!( + method.as_str(), + host::AUTH_LIST | host::AUTH_GET | host::AUTH_GET_RUNTIME | host::AUTH_SAVE + ) { + return accounts + .call(&context, scope, &method, params, &payload) + .await; + } + let http = http::HttpCallbacks { + client: &http, + network: &network, + scope, + call: &call, + maximum_payload, + request_limits: &request_limits, + }; + if method == host::ACCOUNT_HTTP { + return http.account(&accounts, params, payload).await; + } + if matches!( + method.as_str(), + upstream_wire::HTTP_DO + | upstream_wire::HTTP_DO_STREAM + | upstream_wire::HTTP_STREAM_READ + | upstream_wire::HTTP_STREAM_CLOSE + | upstream_wire::WEBSOCKET_OPEN + | upstream_wire::WEBSOCKET_SEND + | upstream_wire::WEBSOCKET_READ + | upstream_wire::WEBSOCKET_CLOSE + ) { + return upstream::dispatch(&http, &method, params, payload).await; + } + if matches!( + method.as_str(), + host::HTTP_DO + | host::HTTP_DO_STREAM + | host::HTTP_STREAM_READ + | host::HTTP_STREAM_CLOSE + ) { + return http.dispatch(&method, params, payload).await; + } + Err(PluginFault::new( + ErrorCode::Unsupported, + "host callback method is not supported", + )) + }) + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http.rs b/backend/crates/gateway-plugin/runtime/src/callback/http.rs index 8ea083253..121eba2f2 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/http.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/http.rs @@ -139,7 +139,7 @@ impl HttpCallbacks<'_> { ) -> Result { let call = self.call; match method { - "host.http.do" | "host.http.do_stream" => { + wire::HTTP_DO | wire::HTTP_DO_STREAM => { let request: wire::HttpRequest = decode(params)?; self.send( HttpRequest { @@ -149,13 +149,13 @@ impl HttpCallbacks<'_> { maximum_body_bytes: self.body_limit(&payload)?, body: payload, }, - method == "host.http.do_stream", + method == wire::HTTP_DO_STREAM, None, self.scope.proxy.as_ref(), ) .await } - "host.http.stream_read" => { + wire::HTTP_STREAM_READ => { if !payload.is_empty() { return Err(invalid()); } @@ -199,7 +199,7 @@ impl HttpCallbacks<'_> { payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()), }) } - "host.http.stream_close" => { + wire::HTTP_STREAM_CLOSE => { if !payload.is_empty() { return Err(invalid()); } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/invocation.rs b/backend/crates/gateway-plugin/runtime/src/callback/invocation.rs new file mode 100644 index 000000000..7ee8e0f0a --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/invocation.rs @@ -0,0 +1,243 @@ +//! 为具体入口登记回调作用域和一次性中间件绑定,不持有 RPC 调度状态 + +use std::sync::{Arc, Weak}; + +use gateway_core::{account::OutboundProxy, lifecycle::CancellationToken}; +use gateway_plugin_sdk::{CallContext, PluginFault}; + +use super::{ + CallbackScope, InvocationContext, MiddlewareBinding, MiddlewareCallback, PluginCallbacks, + denied, upstream, +}; + +impl PluginCallbacks { + pub(crate) fn bind_middleware( + self: &Arc, + resource_scope_id: String, + invocation: Arc, + scope: Arc, + ) -> Result { + let mut pending = self + .pending_middleware + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if pending + .insert(resource_scope_id.clone(), Arc::clone(&invocation)) + .is_some() + { + return Err(gateway_core::engine::middleware::MiddlewareError::InvalidState); + } + Ok(MiddlewareBinding::new( + Arc::clone(self), + resource_scope_id, + invocation, + scope, + )) + } + + pub(super) fn unbind_middleware( + &self, + resource_scope_id: &str, + invocation: &Arc, + ) { + let mut pending = self + .pending_middleware + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if pending + .get(resource_scope_id) + .is_some_and(|pending| Arc::ptr_eq(pending, invocation)) + { + pending.remove(resource_scope_id); + } + } + + pub(crate) fn prepare_data_plane( + &self, + context: &CallContext, + effects: Option>, + extensions: gateway_core::engine::extensions::ExtensionCallScope, + plan: Option, + cancellation: CancellationToken, + request_body_limit: Option, + ) -> Result, PluginFault> { + if !matches!( + context.stage, + gateway_plugin_sdk::Stage::Request + | gateway_plugin_sdk::Stage::Attempt + | gateway_plugin_sdk::Stage::Outbound + | gateway_plugin_sdk::Stage::Routing + | gateway_plugin_sdk::Stage::Scheduling + | gateway_plugin_sdk::Stage::Retry + ) { + return Err(denied()); + } + let mut scope = CallbackScope::for_call(context); + if let Some(effects) = effects { + scope = scope.with_execution_effects(effects); + } + scope.extension_scope = extensions; + scope.request_body_limit = request_body_limit; + scope.origin = plan.map(|plan| InvocationContext { + request_id: context + .request_id + .clone() + .unwrap_or_else(|| context.resource_scope_id.clone()), + call_id: context + .request_id + .clone() + .unwrap_or_else(|| context.resource_scope_id.clone()), + cancellation, + extensions: scope.extension_scope.clone(), + effects: scope.execution_effects.clone(), + plan: Some(plan), + }); + self.register_scope(context, scope) + } + + pub(crate) fn request_body_limit(&self) -> Result { + self.request_limits.current_body_bytes() + } + + pub(crate) fn prepare_upstream( + &self, + context: &CallContext, + managed: Arc, + extension_scope: gateway_core::engine::extensions::ExtensionCallScope, + request_body_limit: usize, + ) -> Result, PluginFault> { + if context.stage != gateway_plugin_sdk::Stage::Upstream + || context.account_id.as_deref() != Some(managed.account.account_id().as_str()) + || context.credential_revision != Some(managed.account.credential_revision().get()) + { + return Err(denied()); + } + let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned()) + .with_upstream(managed); + scope.extension_scope = extension_scope; + scope.request_body_limit = Some(request_body_limit); + self.register_scope(context, scope) + } + + pub(crate) fn prepare_management( + &self, + context: &CallContext, + proxy: Option, + request: Option>, + ) -> Result, PluginFault> { + if !matches!( + context.stage, + gateway_plugin_sdk::Stage::Management | gateway_plugin_sdk::Stage::PublicManagement + ) { + return Err(denied()); + } + self.prepare_scope(context, proxy, request) + } + + pub(crate) fn prepare_command_line( + &self, + context: &CallContext, + ) -> Result, PluginFault> { + if context.stage != gateway_plugin_sdk::Stage::CommandLine { + return Err(denied()); + } + self.prepare_scope(context, None, None) + } + + pub(crate) fn prepare_frontend_authentication( + &self, + context: &CallContext, + request: &gateway_core::engine::authentication::ClientAuthenticationRequest, + ) -> Result, PluginFault> { + if context.stage != gateway_plugin_sdk::Stage::Authentication + || context.account_id.is_some() + || context.credential_revision.is_some() + || context.attempt_id.is_some() + { + return Err(denied()); + } + let mut scope = CallbackScope::for_call(context); + scope.settings = request.settings().cloned(); + if let Some(origin) = request.invocation() { + scope.extension_scope = origin.extensions.clone(); + scope.execution_effects = origin.effects.clone(); + scope.origin = Some(origin.clone()); + } else if let Some(request) = request.shared_request_context() { + scope.extension_scope = scope.extension_scope.with_request(request); + } + self.register_scope(context, scope) + } + + pub(crate) async fn save_command_account( + &self, + context: &CallContext, + scope: &CallbackScope, + request: gateway_plugin_sdk::call::host::AuthSaveRequest, + ) -> Result { + if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) { + return Err(denied()); + } + self.accounts.save(context, scope, request).await + } + + fn prepare_scope( + &self, + context: &CallContext, + proxy: Option, + request: Option>, + ) -> Result, PluginFault> { + let mut scope = CallbackScope::new(context, proxy); + if let Some(request) = request { + scope.extension_scope = scope.extension_scope.with_request(request); + } + self.register_scope(context, scope) + } + + pub(crate) fn prepare_extensions( + &self, + context: &CallContext, + extension_scope: gateway_core::engine::extensions::ExtensionCallScope, + ) -> Result, PluginFault> { + let mut scope = CallbackScope::for_call(context); + scope.extension_scope = extension_scope; + self.register_scope(context, scope) + } + + pub(crate) fn bind_invocation( + self: &Arc, + call: &CallContext, + origin: Option, + extensions: gateway_core::engine::extensions::ExtensionCallScope, + invocation: Arc, + ) -> Result { + let mut scope = CallbackScope::for_call(call); + scope.extension_scope = extensions; + scope.origin = origin; + let scope = self + .register_scope(call, scope) + .map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?; + self.bind_middleware(call.resource_scope_id.clone(), invocation, scope) + } + + fn register_scope( + &self, + context: &CallContext, + scope: CallbackScope, + ) -> Result, PluginFault> { + let scope = Arc::new(scope); + let mut scopes = self + .scopes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + scopes.retain(|_, scope| scope.strong_count() != 0); + if scopes + .get(&context.resource_scope_id) + .and_then(Weak::upgrade) + .is_some() + { + return Err(denied()); + } + scopes.insert(context.resource_scope_id.clone(), Arc::downgrade(&scope)); + Ok(scope) + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/keys.rs b/backend/crates/gateway-plugin/runtime/src/callback/keys.rs index a8fd25bc6..21ac39e90 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/keys.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/keys.rs @@ -52,7 +52,7 @@ impl PluginClientKeys { params: serde_json::Value, payload: &[u8], ) -> Result { - if method == "host.keys.list" { + if method == gateway_plugin_sdk::call::host::KEYS_LIST { return self.ports.list(params, payload).await; } if params != serde_json::json!({}) { diff --git a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs index b8feed5d8..0271616b1 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs @@ -1,4 +1,4 @@ -//! 插件宿主回调端口的组合、授权检查与方法分派 +//! 插件宿主回调端口、调用作用域与分派的模块入口 mod accounts; mod admin; @@ -6,16 +6,19 @@ mod affinity; mod call_resources; mod client; mod data; +mod dispatch; pub(crate) mod error; mod facts; mod http; mod http_dispatch; pub(crate) mod http_middleware; +mod invocation; mod keys; mod log; mod middleware; mod model; pub(crate) mod outbound; +mod ports; pub(crate) mod private_state; pub(crate) mod request_limits; mod resources; @@ -24,22 +27,11 @@ pub(crate) mod services; pub(crate) mod upstream; pub(crate) mod websocket_middleware; -use std::{ - collections::BTreeMap, - sync::{Arc, Mutex, Weak}, -}; - -use futures::future::BoxFuture; -use gateway_admin::model::AdminError; -use gateway_core::{account::OutboundProxy, lifecycle::CancellationToken}; -use gateway_host::outbound::{HttpClient, NetworkPolicy}; -use gateway_plugin_sdk::{CallContext, ErrorCode, PluginFault, client::CallDeadline}; - -use crate::{CallbackHandler, RpcReply}; pub(crate) use accounts::PluginAccountPortSlot; pub(crate) use affinity::PluginAffinityPortSlot; use call_resources::{CallResources, HttpStream}; pub(crate) use client::PluginClientIdentityPortSlot; +use gateway_plugin_sdk::{ErrorCode, PluginFault}; pub(crate) use keys::PluginClientKeyPortSlot; pub(crate) use middleware::{ MiddlewareBinding, MiddlewareBodyAuthority, MiddlewareCallback, MiddlewareCompletionBody, @@ -49,515 +41,7 @@ pub(crate) use model::PluginModelPortSlot; pub(crate) use resources::PluginResourcePorts; pub(crate) use scope::{CallbackScope, InvocationContext}; -pub(crate) struct PluginCallbackPorts { - pub(crate) services: Arc, - pub(crate) http: Arc, - pub(crate) accounts: Arc, - pub(crate) keys: Arc, - pub(crate) clients: Arc, - pub(crate) models: Arc, - pub(crate) affinity: Arc, - pub(crate) resources: Arc, -} - -pub(crate) struct PluginCallbacks { - pub(crate) registration_body_limit: Option, - services: Arc, - service_actor: gateway_admin::model::usage_data::UsageActor, - accounts: Arc, - resources: Arc, - data: Arc, - keys: Arc, - affinity: Arc, - log: Arc, - clients: Arc, - models: Arc, - private_state: Arc, - http: Arc, - network: Arc, - scopes: Mutex>>, - pending_middleware: Mutex>>, - calls: Mutex>>, - maximum_payload: usize, - request_limits: Arc, -} - -impl PluginCallbacks { - pub(crate) fn new( - instance: &gateway_admin::model::plugins::instances::PluginInstance, - maximum_payload: usize, - manifest: &gateway_plugin_sdk::Manifest, - log_slots: Arc, - private_state: Arc, - ports: PluginCallbackPorts, - request_limits: Arc, - ) -> Result { - let clients = Arc::new(client::PluginClients::new(ports.clients)); - Ok(Self { - registration_body_limit: None, - services: ports.services, - service_actor: gateway_admin::model::usage_data::UsageActor::Plugin { - publisher: manifest.publisher.clone(), - name: manifest.name.clone(), - instance_id: instance.id.clone(), - }, - resources: Arc::new(resources::PluginResources::new(instance, ports.resources)), - data: Arc::new(data::PluginData::new( - ports.accounts.clone(), - ports.keys.clone(), - )), - accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)), - keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)), - affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)), - log: Arc::new( - log::PluginLog::new(manifest, log_slots) - .map_err(|_| AdminError::invalid("插件清单身份不合法"))?, - ), - clients: clients.clone(), - models: Arc::new(model::PluginModels::new( - ports.models, - clients, - request_limits.clone(), - maximum_payload, - )), - private_state, - http: ports.http, - network: Arc::new(NetworkPolicy::unrestricted()), - scopes: Mutex::new(BTreeMap::new()), - pending_middleware: Mutex::new(BTreeMap::new()), - calls: Mutex::new(BTreeMap::new()), - maximum_payload, - request_limits, - }) - } - - pub(crate) fn bind_middleware( - self: &Arc, - resource_scope_id: String, - invocation: Arc, - scope: Arc, - ) -> Result { - let mut pending = self - .pending_middleware - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if pending - .insert(resource_scope_id.clone(), Arc::clone(&invocation)) - .is_some() - { - return Err(gateway_core::engine::middleware::MiddlewareError::InvalidState); - } - Ok(MiddlewareBinding::new( - Arc::clone(self), - resource_scope_id, - invocation, - scope, - )) - } - - fn unbind_middleware(&self, resource_scope_id: &str, invocation: &Arc) { - let mut pending = self - .pending_middleware - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if pending - .get(resource_scope_id) - .is_some_and(|pending| Arc::ptr_eq(pending, invocation)) - { - pending.remove(resource_scope_id); - } - } - - pub(crate) fn prepare_data_plane( - &self, - context: &CallContext, - effects: Option>, - extensions: gateway_core::engine::extensions::ExtensionCallScope, - plan: Option, - cancellation: CancellationToken, - request_body_limit: Option, - ) -> Result, PluginFault> { - if !matches!( - context.stage, - gateway_plugin_sdk::Stage::Request - | gateway_plugin_sdk::Stage::Attempt - | gateway_plugin_sdk::Stage::Outbound - | gateway_plugin_sdk::Stage::Routing - | gateway_plugin_sdk::Stage::Scheduling - | gateway_plugin_sdk::Stage::Retry - ) { - return Err(denied()); - } - let mut scope = CallbackScope::for_call(context); - if let Some(effects) = effects { - scope = scope.with_execution_effects(effects); - } - scope.extension_scope = extensions; - scope.request_body_limit = request_body_limit; - scope.origin = plan.map(|plan| InvocationContext { - request_id: context - .request_id - .clone() - .unwrap_or_else(|| context.resource_scope_id.clone()), - call_id: context - .request_id - .clone() - .unwrap_or_else(|| context.resource_scope_id.clone()), - cancellation, - extensions: scope.extension_scope.clone(), - effects: scope.execution_effects.clone(), - plan: Some(plan), - }); - self.register_scope(context, scope) - } - - pub(crate) fn request_body_limit(&self) -> Result { - self.request_limits.current_body_bytes() - } - - pub(crate) fn prepare_upstream( - &self, - context: &CallContext, - managed: Arc, - extension_scope: gateway_core::engine::extensions::ExtensionCallScope, - request_body_limit: usize, - ) -> Result, PluginFault> { - if context.stage != gateway_plugin_sdk::Stage::Upstream - || context.account_id.as_deref() != Some(managed.account.account_id().as_str()) - || context.credential_revision != Some(managed.account.credential_revision().get()) - { - return Err(denied()); - } - let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned()) - .with_upstream(managed); - scope.extension_scope = extension_scope; - scope.request_body_limit = Some(request_body_limit); - self.register_scope(context, scope) - } - - pub(crate) fn prepare_management( - &self, - context: &CallContext, - proxy: Option, - request: Option>, - ) -> Result, PluginFault> { - if !matches!( - context.stage, - gateway_plugin_sdk::Stage::Management | gateway_plugin_sdk::Stage::PublicManagement - ) { - return Err(denied()); - } - self.prepare_scope(context, proxy, request) - } - - pub(crate) fn prepare_command_line( - &self, - context: &CallContext, - ) -> Result, PluginFault> { - if context.stage != gateway_plugin_sdk::Stage::CommandLine { - return Err(denied()); - } - self.prepare_scope(context, None, None) - } - - pub(crate) fn prepare_frontend_authentication( - &self, - context: &CallContext, - request: &gateway_core::engine::authentication::ClientAuthenticationRequest, - ) -> Result, PluginFault> { - if context.stage != gateway_plugin_sdk::Stage::Authentication - || context.account_id.is_some() - || context.credential_revision.is_some() - || context.attempt_id.is_some() - { - return Err(denied()); - } - let mut scope = CallbackScope::for_call(context); - scope.settings = request.settings().cloned(); - if let Some(origin) = request.invocation() { - scope.extension_scope = origin.extensions.clone(); - scope.execution_effects = origin.effects.clone(); - scope.origin = Some(origin.clone()); - } else if let Some(request) = request.shared_request_context() { - scope.extension_scope = scope.extension_scope.with_request(request); - } - self.register_scope(context, scope) - } - - pub(crate) async fn save_command_account( - &self, - context: &CallContext, - scope: &CallbackScope, - request: gateway_plugin_sdk::call::host::AuthSaveRequest, - ) -> Result { - if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) { - return Err(denied()); - } - self.accounts.save(context, scope, request).await - } - - fn prepare_scope( - &self, - context: &CallContext, - proxy: Option, - request: Option>, - ) -> Result, PluginFault> { - let mut scope = CallbackScope::new(context, proxy); - if let Some(request) = request { - scope.extension_scope = scope.extension_scope.with_request(request); - } - self.register_scope(context, scope) - } - - pub(crate) fn prepare_extensions( - &self, - context: &CallContext, - extension_scope: gateway_core::engine::extensions::ExtensionCallScope, - ) -> Result, PluginFault> { - let mut scope = CallbackScope::for_call(context); - scope.extension_scope = extension_scope; - self.register_scope(context, scope) - } - - pub(crate) fn bind_invocation( - self: &Arc, - call: &CallContext, - origin: Option, - extensions: gateway_core::engine::extensions::ExtensionCallScope, - invocation: Arc, - ) -> Result { - let mut scope = CallbackScope::for_call(call); - scope.extension_scope = extensions; - scope.origin = origin; - let scope = self - .register_scope(call, scope) - .map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?; - self.bind_middleware(call.resource_scope_id.clone(), invocation, scope) - } - - fn register_scope( - &self, - context: &CallContext, - scope: CallbackScope, - ) -> Result, PluginFault> { - let scope = Arc::new(scope); - let mut scopes = self - .scopes - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - scopes.retain(|_, scope| scope.strong_count() != 0); - if scopes - .get(&context.resource_scope_id) - .and_then(Weak::upgrade) - .is_some() - { - return Err(denied()); - } - scopes.insert(context.resource_scope_id.clone(), Arc::downgrade(&scope)); - Ok(scope) - } -} - -impl CallbackHandler for PluginCallbacks { - fn begin(&self, context: &CallContext, deadline: &CallDeadline) { - let scope = self - .scopes - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(&context.resource_scope_id) - .and_then(Weak::upgrade) - .unwrap_or_else(|| { - let mut scope = CallbackScope::for_call(context); - if context.stage == gateway_plugin_sdk::Stage::Registration { - scope.request_body_limit = self.registration_body_limit; - } - Arc::new(scope) - }); - let middleware = self - .pending_middleware - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&context.resource_scope_id); - self.calls - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - context.call_id, - Arc::new(CallResources::new(context, deadline, scope, middleware)), - ); - } - - fn finish(&self, context: &CallContext) { - if let Some(call) = self - .calls - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&context.call_id) - { - call.close(); - } - } - - fn call( - &self, - context: CallContext, - method: String, - params: serde_json::Value, - payload: Vec, - ) -> BoxFuture<'static, Result> { - let call = self - .calls - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(&context.call_id) - .cloned(); - let http = self.http.clone(); - let network = self.network.clone(); - let maximum_payload = self.maximum_payload; - let log = self.log.clone(); - let private_state = self.private_state.clone(); - let accounts = self.accounts.clone(); - let resources = self.resources.clone(); - let data = self.data.clone(); - let keys = self.keys.clone(); - let clients = self.clients.clone(); - let models = self.models.clone(); - let request_limits = self.request_limits.clone(); - let affinity = self.affinity.clone(); - let services = self.services.clone(); - let service_actor = self.service_actor.clone(); - Box::pin(async move { - let call = call.ok_or_else(denied)?; - let scope = &call.scope; - let (middleware, is_private_state) = { - let state = call - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if state.closed || call.deadline.is_elapsed() { - return Err(denied()); - } - if method == "host.log" { - return log.record(&context, params, &payload); - } - let middleware = if method.starts_with("host.middleware.") { - Some(state.middleware.clone().ok_or_else(denied)?) - } else { - None - }; - let is_private_state = matches!( - method.as_str(), - "host.state.get" | "host.state.put" | "host.state.delete" - ); - (middleware, is_private_state) - }; - if matches!( - method.as_str(), - gateway_plugin_sdk::call::middleware::http::DISPATCH_METHOD - | gateway_plugin_sdk::call::middleware::http::BODY_READ_METHOD - | gateway_plugin_sdk::call::middleware::http::BODY_CLOSE_METHOD - | gateway_plugin_sdk::call::middleware::http::BODY_CREATE_METHOD - | gateway_plugin_sdk::call::middleware::http::BODY_WRITE_METHOD - ) { - return tokio::select! { - biased; - () = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")), - result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result, - }; - } - if let Some(middleware) = middleware { - return middleware - .invoke(method, params, payload, maximum_payload) - .await; - } - if method == gateway_plugin_sdk::call::services::CALL_METHOD { - return services - .call(&context, &call, service_actor, params, &payload) - .await; - } - if is_private_state { - return private_state.call(&method, params, &payload).await; - } - if matches!( - method.as_str(), - gateway_plugin_sdk::call::resources::GROUP_ENSURE - | gateway_plugin_sdk::call::resources::GROUP_MEMBERS - | gateway_plugin_sdk::call::resources::KEY_ENSURE - ) { - return resources.call(&context, &method, params, &payload).await; - } - if matches!( - method.as_str(), - "host.keys.list" - | gateway_plugin_sdk::call::key_budgets::RESET - | gateway_plugin_sdk::call::key_budgets::GET - | gateway_plugin_sdk::call::key_budgets::UPDATE_LIMITS - ) { - return keys.call(&context, &method, params, &payload).await; - } - if matches!( - method.as_str(), - gateway_plugin_sdk::call::data::CLIENT_VERIFY - | gateway_plugin_sdk::call::data::ACCESS_CHECK - ) { - return clients - .call(&context, &call, &method, params, payload) - .await; - } - if method == "host.models.list" { - return models.call(&context, &call, &method, params, payload).await; - } - if matches!( - method.as_str(), - "host.model.execute" - | "host.model.execute_stream" - | "host.model.stream_read" - | "host.model.stream_close" - ) { - return models.call(&context, &call, &method, params, payload).await; - } - if method == "host.affinity.lookup" { - return affinity.call(&context, params, &payload).await; - } - let scope = Some(scope) - .filter(|scope| scope.authorizes(&context)) - .ok_or_else(denied)?; - if matches!( - method.as_str(), - gateway_plugin_sdk::call::data::ACCOUNTS_LIST - | gateway_plugin_sdk::call::data::KEYS_GET - | gateway_plugin_sdk::call::data::QUOTA_GET - | gateway_plugin_sdk::call::data::QUOTA_REFRESH - | gateway_plugin_sdk::call::data::QUOTA_DETAILS - ) { - return data.call(&method, params, &payload).await; - } - if matches!( - method.as_str(), - "host.auth.list" | "host.auth.get" | "host.auth.get_runtime" | "host.auth.save" - ) { - return accounts - .call(&context, scope, &method, params, &payload) - .await; - } - let http = http::HttpCallbacks { - client: &http, - network: &network, - scope, - call: &call, - maximum_payload, - request_limits: &request_limits, - }; - if method == gateway_plugin_sdk::call::host::ACCOUNT_HTTP { - return http.account(&accounts, params, payload).await; - } - if method.starts_with("host.upstream.") { - return upstream::dispatch(&http, &method, params, payload).await; - } - http.dispatch(&method, params, payload).await - }) - } -} +pub(crate) use ports::{PluginCallbackPorts, PluginCallbacks}; fn denied() -> PluginFault { PluginFault::new( diff --git a/backend/crates/gateway-plugin/runtime/src/callback/model.rs b/backend/crates/gateway-plugin/runtime/src/callback/model.rs index 624782ded..999321c4e 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/model.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/model.rs @@ -26,7 +26,7 @@ use gateway_core::{ use gateway_plugin_sdk::{ CallContext, ErrorCode, PluginFault, call::host::{ - ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest, + self as wire, ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest, ModelListResult, ModelOperation, ModelStreamCloseRequest, ModelStreamReadRequest, ModelStreamReadResult, ModelStreamResult, }, @@ -90,7 +90,7 @@ impl PluginModels { payload: Vec, ) -> Result { match method { - "host.models.list" => { + wire::MODELS_LIST => { if !payload.is_empty() { return Err(invalid()); } @@ -120,7 +120,7 @@ impl PluginModels { .collect(), }) } - "host.model.execute" | "host.model.execute_stream" => { + wire::MODEL_EXECUTE | wire::MODEL_EXECUTE_STREAM => { let mut request: ModelExecuteRequest = serde_json::from_value(params).map_err(|_| invalid())?; if payload.is_empty() { @@ -135,7 +135,7 @@ impl PluginModels { } }; let port = self.slot.upgrade()?; - let stream = method == "host.model.execute_stream"; + let stream = method == wire::MODEL_EXECUTE_STREAM; let started = if let Some(client_key_id) = request.client_key_id.take() { let client = self.clients.identity(call, client_key_id).await?; check_size(client.snapshot().responses_max_decompressed_body_bytes())?; @@ -150,7 +150,7 @@ impl PluginModels { let request = self.request(context, request, payload, stream)?; port.start(request).await.map_err(super::error::gateway)? }; - if method == "host.model.execute" { + if method == wire::MODEL_EXECUTE { execute_buffered(started, self.maximum_payload).await } else { let stream = Arc::new(ModelStream::new(started.session, self.maximum_payload)); @@ -174,7 +174,7 @@ impl PluginModels { }) } } - "host.model.stream_read" => { + wire::MODEL_STREAM_READ => { if !payload.is_empty() { return Err(invalid()); } @@ -213,7 +213,7 @@ impl PluginModels { payload: read.payload, }) } - "host.model.stream_close" => { + wire::MODEL_STREAM_CLOSE => { if !payload.is_empty() { return Err(invalid()); } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/ports.rs b/backend/crates/gateway-plugin/runtime/src/callback/ports.rs new file mode 100644 index 000000000..74170033a --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/callback/ports.rs @@ -0,0 +1,102 @@ +//! 组装实例宿主端口及调用资源索引,保持资源与插件实例同寿命 + +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex, Weak}, +}; + +use gateway_admin::model::AdminError; +use gateway_host::outbound::{HttpClient, NetworkPolicy}; + +use super::{ + CallResources, CallbackScope, MiddlewareCallback, PluginAccountPortSlot, + PluginAffinityPortSlot, PluginClientIdentityPortSlot, PluginClientKeyPortSlot, + PluginModelPortSlot, PluginResourcePorts, +}; +use super::{ + accounts, affinity, client, data, keys, log, model, private_state, request_limits, resources, + services, +}; + +pub(crate) struct PluginCallbackPorts { + pub(crate) services: Arc, + pub(crate) http: Arc, + pub(crate) accounts: Arc, + pub(crate) keys: Arc, + pub(crate) clients: Arc, + pub(crate) models: Arc, + pub(crate) affinity: Arc, + pub(crate) resources: Arc, +} + +pub(crate) struct PluginCallbacks { + pub(crate) registration_body_limit: Option, + pub(super) services: Arc, + pub(super) service_actor: gateway_admin::model::usage_data::UsageActor, + pub(super) accounts: Arc, + pub(super) resources: Arc, + pub(super) data: Arc, + pub(super) keys: Arc, + pub(super) affinity: Arc, + pub(super) log: Arc, + pub(super) clients: Arc, + pub(super) models: Arc, + pub(super) private_state: Arc, + pub(super) http: Arc, + pub(super) network: Arc, + pub(super) scopes: Mutex>>, + pub(super) pending_middleware: Mutex>>, + pub(super) calls: Mutex>>, + pub(super) maximum_payload: usize, + pub(super) request_limits: Arc, +} + +impl PluginCallbacks { + pub(crate) fn new( + instance: &gateway_admin::model::plugins::instances::PluginInstance, + maximum_payload: usize, + manifest: &gateway_plugin_sdk::Manifest, + log_slots: Arc, + private_state: Arc, + ports: PluginCallbackPorts, + request_limits: Arc, + ) -> Result { + let clients = Arc::new(client::PluginClients::new(ports.clients)); + Ok(Self { + registration_body_limit: None, + services: ports.services, + service_actor: gateway_admin::model::usage_data::UsageActor::Plugin { + publisher: manifest.publisher.clone(), + name: manifest.name.clone(), + instance_id: instance.id.clone(), + }, + resources: Arc::new(resources::PluginResources::new(instance, ports.resources)), + data: Arc::new(data::PluginData::new( + ports.accounts.clone(), + ports.keys.clone(), + )), + accounts: Arc::new(accounts::PluginAccounts::new(ports.accounts)), + keys: Arc::new(keys::PluginClientKeys::new(instance, ports.keys)), + affinity: Arc::new(affinity::PluginAffinity::new(ports.affinity)), + log: Arc::new( + log::PluginLog::new(manifest, log_slots) + .map_err(|_| AdminError::invalid("插件清单身份不合法"))?, + ), + clients: clients.clone(), + models: Arc::new(model::PluginModels::new( + ports.models, + clients, + request_limits.clone(), + maximum_payload, + )), + private_state, + http: ports.http, + network: Arc::new(NetworkPolicy::unrestricted()), + scopes: Mutex::new(BTreeMap::new()), + pending_middleware: Mutex::new(BTreeMap::new()), + calls: Mutex::new(BTreeMap::new()), + maximum_payload, + request_limits, + }) + } +} diff --git a/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs b/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs index 937fc60f2..3ffc3c93c 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/private_state.rs @@ -21,8 +21,8 @@ use gateway_admin::{ use gateway_plugin_sdk::{ ErrorCode, Manifest, PluginFault, call::host::{ - StateDeleteRequest, StateDeleteResult, StateGetRequest, StateGetResult, StatePutRequest, - StatePutResult, StateRecord, + self as wire, StateDeleteRequest, StateDeleteResult, StateGetRequest, StateGetResult, + StatePutRequest, StatePutResult, StateRecord, }, }; use sha2::{Digest as _, Sha256}; @@ -99,7 +99,7 @@ impl PluginPrivateState { .clone() .ok_or_else(denied_fault)?; let result = match method { - "host.state.get" => { + wire::STATE_GET => { let request: StateGetRequest = serde_json::from_value(params).map_err(|_| invalid_fault())?; self.authorize(&request.namespace)?; @@ -115,7 +115,7 @@ impl PluginPrivateState { }); serde_json::to_value(StateGetResult { record }).map_err(|_| internal_fault())? } - "host.state.put" => { + wire::STATE_PUT => { let request: StatePutRequest = serde_json::from_value(params).map_err(|_| invalid_fault())?; self.authorize(&request.namespace)?; @@ -141,7 +141,7 @@ impl PluginPrivateState { }) .map_err(|_| internal_fault())? } - "host.state.delete" => { + wire::STATE_DELETE => { let request: StateDeleteRequest = serde_json::from_value(params).map_err(|_| invalid_fault())?; self.authorize(&request.namespace)?; diff --git a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs index 472cc47d6..513025bbc 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/upstream/mod.rs @@ -193,31 +193,31 @@ pub(super) async fn dispatch( .map(|remaining| remaining.min(Duration::from_secs(120))) }; match method { - "host.upstream.http.do" | "host.upstream.http.do_stream" => { + adapter::HTTP_DO | adapter::HTTP_DO_STREAM => { let request = serde_json::from_value(params).map_err(|_| invalid())?; let maximum_body_bytes = http.body_limit(&payload)?; let (request, purpose) = managed.request(request, payload, maximum_body_bytes)?; http.send( request, - method.ends_with("do_stream"), + method == adapter::HTTP_DO_STREAM, Some(purpose), scope.proxy.as_ref(), ) .await } - "host.upstream.http.stream_read" | "host.upstream.http.stream_close" => { + adapter::HTTP_STREAM_READ | adapter::HTTP_STREAM_CLOSE => { http.dispatch( - if method.ends_with("stream_read") { - "host.http.stream_read" + if method == adapter::HTTP_STREAM_READ { + wire::HTTP_STREAM_READ } else { - "host.http.stream_close" + wire::HTTP_STREAM_CLOSE }, params, payload, ) .await } - "host.upstream.websocket.open" => { + adapter::WEBSOCKET_OPEN => { if !payload.is_empty() || !managed.websocket_allowed { return Err(denied()); } @@ -303,7 +303,7 @@ pub(super) async fn dispatch( payload, }) } - "host.upstream.websocket.send" => { + adapter::WEBSOCKET_SEND => { let message: adapter::UpstreamWebSocketMessage = serde_json::from_value(params).map_err(|_| invalid())?; let message = match message.kind { @@ -327,7 +327,7 @@ pub(super) async fn dispatch( payload: vec![], }) } - "host.upstream.websocket.read" => { + adapter::WEBSOCKET_READ => { if params != serde_json::json!({}) || !payload.is_empty() { return Err(invalid()); } @@ -358,7 +358,7 @@ pub(super) async fn dispatch( payload, }) } - "host.upstream.websocket.close" => { + adapter::WEBSOCKET_CLOSE => { if params != serde_json::json!({}) || !payload.is_empty() { return Err(invalid()); } diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs index 27a1a63ed..8c21ed31a 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/diagnostics.rs @@ -10,7 +10,7 @@ use gateway_admin::model::{ AdminError, AdminErrorKind, Revision, plugins::instances::{ PluginInstance, PluginInstanceRuntime, PluginInstanceRuntimeFailure, - PluginInstanceRuntimeStatus, PluginInstanceSnapshot, + PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus, PluginInstanceSnapshot, }, }; use gateway_core::routing::extensions::ExtensionSetReference; @@ -227,6 +227,17 @@ impl InstanceRuntimeProjection<'_> { actual_artifact_sha256: active.map(|instance| instance.artifact_sha256.clone()), failure, draining_revisions: draining_revisions.into_iter().collect(), + load: active.map(|instance| { + let load = instance.session.load(); + PluginInstanceRuntimeLoad { + active_calls: load.active_calls, + executing_calls: load.executing_calls, + queued_calls: load.queued_calls, + resuming_calls: load.resuming_calls, + pending_callbacks: load.pending_callbacks, + active_streams: load.active_streams, + } + }), } } } diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/admission.rs b/backend/crates/gateway-plugin/runtime/src/rpc/admission.rs index 209aad4a8..0640c6b75 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/admission.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/admission.rs @@ -47,4 +47,11 @@ impl ExecutionCapacity { self.active.close(); self.queued.close(); } + + pub(super) fn available(&self) -> (usize, usize) { + ( + self.active.available_permits(), + self.queued.available_permits(), + ) + } } diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs b/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs new file mode 100644 index 000000000..50014faa6 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/rpc/calls.rs @@ -0,0 +1,658 @@ +//! RPC 调用表唯一持有调度、回调、响应流和取消确认,统一完成调用资源回收 + +use std::{ + collections::BTreeMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use gateway_host::process::ProcessControl; +use gateway_plugin_sdk::{ + CallContext, Frame, Message, PluginFault, + client::{CallDeadline, CallDeadlinePause, EncodedFrame}, +}; +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch}; + +use super::{ + CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSessionLoad, admission::ExecutionCapacity, +}; +use crate::stream::StreamIngress; + +pub(crate) struct Shared { + state: Mutex, + started_at: Instant, + pub(super) expected_stop: AtomicBool, + failure_observer: Option>, + pub control: mpsc::Sender, + pub process: ProcessControl, + pub stopped: watch::Sender, + callback_slots: Arc, + pub(super) execution: ExecutionCapacity, + cancellations_changed: Notify, +} + +struct State { + failure: Option, + calls: BTreeMap, +} + +struct PendingCall { + _slot: Arc, + execution: Option, + deadline: CallDeadline, + handler: Arc, + transmitted: bool, + cancellation: Option, + context: CallContext, + result: Option>>, + stream: Option, + scheduling: Option, + callbacks: BTreeMap, +} + +// 调度状态只属于父任务;回调完成与恢复授权分别推进 +enum Scheduling { + Running { + epoch: u64, + }, + Suspended { + epoch: u64, + pause: Option, + }, + Resuming { + epoch: u64, + pause: Option, + task: tokio::task::AbortHandle, + }, +} + +struct CallbackTask { + task: tokio::task::AbortHandle, + pauses_budget: bool, + _permit: OwnedSemaphorePermit, +} + +impl Drop for CallbackTask { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl PendingCall { + /// 关闭资源与封存副作用必须先于任何结果通知;取消确认只延续容量租约 + fn revoke(&mut self) { + self.handler.finish(&self.context); + self.callbacks.clear(); + self.stop_resumption(); + } + + fn stop_resumption(&mut self) { + if let Some(Scheduling::Resuming { task, .. }) = self.scheduling.take() { + task.abort(); + } + } + + fn finish(mut self, result: Result) { + tracing::debug!( + event = "plugin.rpc.finished", + instance_id = self.context.instance_id, + call_id = self.context.call_id, + stage = ?self.context.stage, + outcome = result.as_ref().map_or_else(|error| error.diagnostic().0, |_| "success"), + "插件 RPC 调用结束" + ); + let stream = self.stream.take(); + let sender = self.result.take(); + // 先同步关闭调用资源,Core 才能在收到结果时看到全部发送副作用 + drop(self); + if let Some(stream) = stream { + stream.finish(result.as_ref().map(|_| ()).map_err(Clone::clone)); + } + if let Some(sender) = sender { + let _ = sender.send(result); + } + } +} + +impl Drop for PendingCall { + fn drop(&mut self) { + self.revoke(); + } +} + +impl Shared { + pub(super) fn new( + limits: RpcLimits, + process: ProcessControl, + control: mpsc::Sender, + failure_observer: Option>, + ) -> Self { + let (stopped, _) = watch::channel(false); + Self { + state: Mutex::new(State { + failure: None, + calls: BTreeMap::new(), + }), + started_at: Instant::now(), + expected_stop: AtomicBool::new(false), + failure_observer, + control, + process, + stopped, + callback_slots: Arc::new(Semaphore::new(limits.maximum_callbacks)), + execution: ExecutionCapacity::new( + limits.maximum_executing_calls, + limits.maximum_queued_calls, + limits.maximum_queue_timeout, + ), + cancellations_changed: Notify::new(), + } + } + + pub(super) fn failure_diagnostic(&self) -> Option<(&'static str, &'static str)> { + self.state().failure.as_ref().map(RpcError::diagnostic) + } + + pub(super) fn load(&self, limits: RpcLimits, available_calls: usize) -> RpcSessionLoad { + let state = self.state(); + let (available_execution, available_queue) = self.execution.available(); + RpcSessionLoad { + active_calls: limits.maximum_calls - available_calls, + executing_calls: limits.maximum_executing_calls - available_execution, + queued_calls: limits.maximum_queued_calls - available_queue, + resuming_calls: state + .calls + .values() + .filter(|call| matches!(call.scheduling, Some(Scheduling::Resuming { .. }))) + .count(), + pending_callbacks: limits.maximum_callbacks - self.callback_slots.available_permits(), + active_streams: state + .calls + .values() + .filter(|call| call.stream.is_some() && call.result.is_none()) + .count(), + } + } + + pub(super) fn begin_call( + &self, + context: CallContext, + deadline: tokio::time::Instant, + stream: Option, + slot: Arc, + execution: OwnedSemaphorePermit, + handler: Arc, + ) -> Result { + let id = context.call_id; + let (result, received) = oneshot::channel(); + let mut state = self.state(); + if let Some(error) = &state.failure { + return Err(error.clone()); + } + let deadline = CallDeadline::new(deadline); + handler.begin(&context, &deadline); + state.calls.insert( + id, + PendingCall { + _slot: slot, + execution: Some(execution), + deadline: deadline.clone(), + handler, + transmitted: false, + cancellation: None, + context, + result: Some(result), + stream, + scheduling: Some(Scheduling::Running { epoch: 0 }), + callbacks: BTreeMap::new(), + }, + ); + Ok(StartedCall { + id, + deadline, + received, + }) + } + + /// 替换维护任务前等待已取消的调用确认;未确认仍沿用会话的有界故障回收 + pub(crate) async fn settle_cancellations(&self) { + let ids: Vec<_> = self + .state() + .calls + .iter() + .filter_map(|(id, call)| call.cancellation.is_some().then_some(*id)) + .collect(); + loop { + let changed = self.cancellations_changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if !ids.iter().any(|id| self.is_cancelling(*id)) { + break; + } + changed.await; + } + if self.failure_diagnostic().is_some() { + self.process.exited().await; + } + } + + fn state(&self) -> std::sync::MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn fail(&self, error: RpcError) { + let uptime = self.started_at.elapsed(); + let (calls, failure_observer) = { + let mut state = self.state(); + if state.failure.is_some() { + return; + } + state.failure = Some(error.clone()); + let observer = (!self.expected_stop.load(Ordering::Acquire)) + .then(|| self.failure_observer.clone()) + .flatten(); + (std::mem::take(&mut state.calls), observer) + }; + self.execution.close(); + for call in calls.into_values() { + call.finish(Err(error.clone())); + } + if let Some(observer) = failure_observer { + observer(uptime); + } + self.stopped.send_replace(true); + self.process.stop(); + self.cancellations_changed.notify_waiters(); + } + + pub(super) fn expect_stop(&self) { + self.expected_stop.store(true, Ordering::Release); + } + + pub fn finish(&self, id: u64, result: Result) -> Result<(), RpcError> { + let mut state = self.state(); + let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?; + if call.cancellation.is_some() { + return Ok(()); + } + if matches!(&result, Err(RpcError::Remote(fault)) if fault.code == gateway_plugin_sdk::ErrorCode::Timeout) + { + log_timeout(&call.context, "plugin", "initial_reply", "remote_timeout"); + } + if result.is_ok() && !matches!(call.scheduling, Some(Scheduling::Running { .. })) { + return Err(RpcError::Protocol(None)); + } + if call.stream.is_some() && result.is_ok() { + if call.context.resource_stream { + call.deadline.clear(); + } + let initial = call.result.take().ok_or(RpcError::Protocol(None))?; + call.execution.take(); + call.scheduling.take(); + call.handler.streaming(&call.context); + drop(state); + let _ = initial.send(result); + } else { + let pending = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?; + drop(state); + pending.finish(result); + } + Ok(()) + } + + pub fn stream_chunk(&self, id: u64, sequence: u64, payload: Vec) -> Result<(), RpcError> { + let mut state = self.state(); + let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?; + if call.cancellation.is_some() { + return Ok(()); + } + if call.result.is_some() { + return Err(RpcError::Protocol(None)); + } + call.stream + .as_mut() + .ok_or(RpcError::Protocol(None))? + .receive(sequence, payload) + } + + pub fn stream_end(&self, id: u64, error: Option) -> Result<(), RpcError> { + let mut state = self.state(); + if state + .calls + .get(&id) + .is_some_and(|call| call.cancellation.is_some()) + { + return Ok(()); + } + let call = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?; + drop(state); + if call.stream.is_none() || call.result.is_some() { + call.finish(Err(RpcError::Protocol(None))); + return Err(RpcError::Protocol(None)); + } + let result = error.map_or_else( + || { + Ok(RpcReply { + result: serde_json::Value::Null, + payload: Vec::new(), + }) + }, + |error| Err(RpcError::Remote(error)), + ); + call.finish(result); + Ok(()) + } + + pub fn release_stream_credit(&self, id: u64, bytes: u32) -> Result<(), RpcError> { + let mut state = self.state(); + // 完成后仍可消费已排队数据,无须再给已关闭的流发信用 + let Some(call) = state.calls.get_mut(&id) else { + return Ok(()); + }; + call.stream + .as_mut() + .ok_or(RpcError::Protocol(None))? + .release(bytes)?; + drop(state); + self.send_control(Frame::control(Message::Credit { + id, + bytes, + frames: 1, + })); + Ok(()) + } + + pub fn context(&self, id: u64) -> Option { + self.state() + .calls + .get(&id) + .filter(|call| call.cancellation.is_none()) + .map(|call| call.context.clone()) + } + + pub fn is_cancelling(&self, id: u64) -> bool { + self.state() + .calls + .get(&id) + .is_some_and(|call| call.cancellation.is_some()) + } + + pub fn mark_transmitted(&self, id: u64) -> bool { + let mut state = self.state(); + let Some(call) = state.calls.get_mut(&id) else { + return false; + }; + if call.cancellation.is_some() { + return false; + } + call.transmitted = true; + true + } + + /// 先撤销调用权限,再等待对端确认;只有无响应对端才终止整个 incarnation + pub fn cancel(self: &Arc, id: u64, error: RpcError) { + let mut state = self.state(); + let Some(call) = state.calls.get_mut(&id) else { + return; + }; + if call.cancellation.is_some() { + return; + } + if matches!(error, RpcError::Timeout) { + log_timeout( + &call.context, + "runtime", + if call.result.is_some() { + "initial_reply" + } else { + "response_stream" + }, + "processing_budget_exhausted", + ); + } + if !call.transmitted { + let call = state.calls.remove(&id); + drop(state); + if let Some(call) = call { + call.finish(Err(error)); + } + return; + } + call.cancellation = Some(error.clone()); + // 同步撤销不能跨越取消确认:收到确认的并发任务可能立即复用实例 + // CallbackHandler::finish 合同要求非阻塞;真正的等待与结果交付放在锁外 + call.revoke(); + let result = call.result.take(); + let stream = call.stream.take(); + tracing::debug!( + event = "plugin.rpc.cancelled", + instance_id = call.context.instance_id, + call_id = id, + stage = ?call.context.stage, + reason = error.diagnostic().0, + "插件 RPC 调用已撤销,等待进程确认" + ); + drop(state); + if let Some(stream) = stream { + stream.finish(Err(error.clone())); + } + if let Some(result) = result { + let _ = result.send(Err(error.clone())); + } + self.send_control(Frame::control(Message::Cancel { id })); + let shared = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(250)).await; + if shared.is_cancelling(id) { + shared.fail(error); + } + }); + } + + pub fn acknowledge_cancellation(&self, id: u64) -> Result<(), RpcError> { + let mut state = self.state(); + if !state + .calls + .get(&id) + .is_some_and(|call| call.cancellation.is_some()) + { + return Err(RpcError::Protocol(None)); + } + let call = state.calls.remove(&id); + drop(state); + drop(call); + self.cancellations_changed.notify_waiters(); + Ok(()) + } + + pub fn track_callback( + &self, + parent_id: u64, + id: u64, + method: &str, + task: tokio::task::AbortHandle, + permit: OwnedSemaphorePermit, + ) -> bool { + let mut state = self.state(); + let Some(call) = state + .calls + .get_mut(&parent_id) + .filter(|call| call.cancellation.is_none()) + else { + task.abort(); + return false; + }; + let pauses_budget = method == gateway_plugin_sdk::call::middleware::NEXT_METHOD; + if !pauses_budget + && let Some(Scheduling::Suspended { pause, .. } | Scheduling::Resuming { pause, .. }) = + call.scheduling.as_mut() + { + pause.take(); + } + call.callbacks.insert( + id, + CallbackTask { + task, + pauses_budget, + _permit: permit, + }, + ); + true + } + + pub(super) fn consume_callback(&self, parent_id: u64, id: u64) -> Result<(), RpcError> { + if id == 0 || !id.is_multiple_of(2) || parent_id == 0 || parent_id.is_multiple_of(2) { + return Err(RpcError::Protocol(None)); + } + // 取消确认可以与消费确认交错;确认只能撤销资源,不能重新创建调用 + if let Some(call) = self.state().calls.get_mut(&parent_id) { + call.callbacks.remove(&id); + } + Ok(()) + } + + pub(super) fn suspend(&self, id: u64, epoch: u64) -> Result<(), RpcError> { + if id == 0 || id.is_multiple_of(2) || epoch == 0 { + return Err(RpcError::Protocol(None)); + } + let mut state = self.state(); + let Some(call) = state.calls.get_mut(&id) else { + return Ok(()); + }; + if call.cancellation.is_some() { + return Ok(()); + } + match call.scheduling.as_ref() { + Some(Scheduling::Running { epoch: previous }) + if previous.checked_add(1) == Some(epoch) => {} + _ => return Err(RpcError::Protocol(None)), + } + let pauses = !call.callbacks.is_empty() + && call + .callbacks + .values() + .all(|callback| callback.pauses_budget); + let pause = pauses.then(|| call.deadline.pause()).flatten(); + call.execution.take(); + call.scheduling = Some(Scheduling::Suspended { epoch, pause }); + Ok(()) + } + + pub(super) fn request_resume(self: &Arc, id: u64, epoch: u64) -> Result<(), RpcError> { + if id == 0 || id.is_multiple_of(2) || epoch == 0 { + return Err(RpcError::Protocol(None)); + } + let mut state = self.state(); + let Some(call) = state.calls.get_mut(&id) else { + return Ok(()); + }; + if call.cancellation.is_some() { + return Ok(()); + } + if !matches!(call.scheduling, Some(Scheduling::Suspended { epoch: current, .. }) if current == epoch) + { + return Err(RpcError::Protocol(None)); + } + let Some(Scheduling::Suspended { pause, .. }) = call.scheduling.take() else { + return Err(RpcError::Protocol(None)); + }; + let shared = Arc::clone(self); + let (ready, started) = oneshot::channel(); + let task = tokio::spawn(async move { + if started.await.is_err() { + return; + } + match shared.execution.resume().await { + Ok(permit) => shared.grant_resume(id, epoch, permit), + Err(error) => shared.cancel(id, error), + } + }); + call.scheduling = Some(Scheduling::Resuming { + epoch, + pause, + task: task.abort_handle(), + }); + let _ = ready.send(()); + Ok(()) + } + + fn grant_resume(&self, id: u64, epoch: u64, permit: OwnedSemaphorePermit) { + let mut state = self.state(); + let Some(call) = state + .calls + .get_mut(&id) + .filter(|call| call.cancellation.is_none()) + else { + return; + }; + if !matches!(call.scheduling, Some(Scheduling::Resuming { epoch: current, .. }) if current == epoch) + { + return; + } + call.execution = Some(permit); + call.scheduling = Some(Scheduling::Running { epoch }); + drop(state); + self.send_control(Frame::control(Message::Resumed { id, epoch })); + } + + pub fn send_control(&self, frame: Frame) { + let Ok(frame) = EncodedFrame::new(frame) else { + self.fail(RpcError::Protocol(None)); + return; + }; + self.send_encoded_control(frame); + } + + pub(super) fn send_encoded_control(&self, frame: EncodedFrame) { + if self.control.try_send(frame).is_err() { + self.fail(RpcError::Capacity); + } + } + + pub(super) fn try_callback_slot(&self) -> Option { + self.callback_slots.clone().try_acquire_owned().ok() + } + + pub(super) async fn wait_for_callbacks(&self, limits: RpcLimits) { + if let Ok(permits) = self + .callback_slots + .clone() + .acquire_many_owned(limits.maximum_callbacks as u32) + .await + { + drop(permits); + } + } +} + +pub(super) struct StartedCall { + pub(super) id: u64, + pub(super) deadline: CallDeadline, + pub(super) received: oneshot::Receiver>, +} + +fn log_timeout( + context: &CallContext, + owner: &'static str, + phase: &'static str, + reason: &'static str, +) { + tracing::warn!( + event = "plugin.rpc.timeout", + owner, + phase, + instance_id = context.instance_id, + call_id = context.call_id, + request_id = context.request_id, + attempt_id = context.attempt_id, + stage = ?context.stage, + budget_ms = context.timeout_ms, + reason, + "插件 RPC 调用超时" + ); +} diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/contract.rs b/backend/crates/gateway-plugin/runtime/src/rpc/contract.rs new file mode 100644 index 000000000..654828840 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/src/rpc/contract.rs @@ -0,0 +1,192 @@ +//! RPC 会话的容量、错误、回调与生命周期合同 + +use std::{sync::Arc, time::Duration}; + +use futures::future::BoxFuture; +use gateway_host::process::ProcessStartError; +use gateway_plugin_sdk::{CallBudgetError, CallContext, PluginFault, Stage, client::CallDeadline}; + +#[derive(Debug, Clone, Copy)] +pub struct RpcLimits { + /// 受管回调与观察队列的缓冲预算,不是请求正文或 IPC 消息总量上限 + pub maximum_buffered_body_bytes: usize, + /// 包含初次排队、下游等待、取消确认和未消费流的总在途上限 + pub maximum_calls: usize, + pub maximum_executing_calls: usize, + pub maximum_queued_calls: usize, + pub maximum_queue_timeout: Duration, + pub maximum_callbacks: usize, + pub handshake_timeout: Duration, + pub maximum_stderr_bytes: usize, +} + +impl Default for RpcLimits { + fn default() -> Self { + Self { + maximum_buffered_body_bytes: 1024 * 1024, + maximum_calls: 256, + maximum_executing_calls: 64, + maximum_queued_calls: 64, + maximum_queue_timeout: Duration::from_secs(30), + maximum_callbacks: 320, + handshake_timeout: Duration::from_secs(5), + maximum_stderr_bytes: 64 * 1024, + } + } +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum RpcError { + #[error("plugin process could not be started: {0}")] + Start(#[source] ProcessStartError), + #[error("plugin handshake does not match the prepared instance")] + Handshake(#[source] Option), + #[error("plugin call budget negotiation failed: {0}")] + Budget(#[from] CallBudgetError), + #[error("plugin protocol is invalid")] + Protocol(#[source] Option), + #[error("plugin returned an invalid response at {0:?}")] + InvalidResponse(Stage, #[source] Option), + #[error("plugin process or transport has stopped")] + Closed(#[source] Option), + #[error("plugin call exceeded its deadline")] + Timeout, + #[error("plugin session was cancelled")] + Cancelled, + #[error("plugin call capacity is exhausted")] + Capacity, + #[error("plugin admission capacity is exhausted")] + Overloaded, + #[error("plugin call context is invalid")] + Context(#[source] Option), + #[error("plugin returned an error")] + Remote(PluginFault), +} + +impl RpcError { + pub(crate) fn is_overloaded(&self) -> bool { + matches!(self, Self::Overloaded) + || matches!(self, Self::Remote(fault) if fault.code == gateway_plugin_sdk::ErrorCode::Capacity) + } + + /// 会话状态与请求错误共用安全分类,不展开插件返回正文 + pub(crate) fn diagnostic(&self) -> (&'static str, &'static str) { + match self { + RpcError::Start(_) => ("process_start", "插件进程启动失败"), + RpcError::Handshake(_) => ("handshake", "插件握手失败"), + RpcError::Budget(_) => ("call_budget", "插件调用预算协商失败"), + RpcError::Protocol(_) => ("protocol", "插件协议错误"), + RpcError::InvalidResponse(stage, _) => ( + "invalid_response", + match stage { + Stage::Registration => "注册阶段:插件返回了无效响应", + Stage::Configuration => "配置阶段:插件返回了无效响应", + Stage::Authentication => "认证阶段:插件返回了无效响应", + Stage::Routing => "路由阶段:插件返回了无效响应", + Stage::Scheduling => "调度阶段:插件返回了无效响应", + Stage::Retry => "重试阶段:插件返回了无效响应", + Stage::Http => "HTTP 中间件:插件返回了无效响应", + Stage::Service => "服务中间件:插件返回了无效响应", + Stage::WebSocket => "WebSocket 中间件:插件返回了无效响应", + Stage::Request => "请求中间件:插件返回了无效响应", + Stage::Attempt => "尝试中间件:插件返回了无效响应", + Stage::Outbound => "出站准备中间件:插件返回了无效响应", + Stage::Upstream => "上游适配阶段:插件返回了无效响应", + Stage::Observation => "观察阶段:插件返回了无效响应", + Stage::Management | Stage::PublicManagement => "管理阶段:插件返回了无效响应", + Stage::CommandLine => "命令行阶段:插件返回了无效响应", + Stage::Maintenance => "维护阶段:插件返回了无效响应", + }, + ), + RpcError::Closed(_) => ("closed", "插件进程或传输已停止"), + RpcError::Timeout => ("timeout", "插件调用超时"), + RpcError::Cancelled => ("cancelled", "插件会话已取消"), + RpcError::Capacity => ("capacity", "插件调用容量已耗尽"), + RpcError::Overloaded => ("overloaded", "插件暂时繁忙"), + RpcError::Context(_) => ("context", "插件调用上下文无效"), + RpcError::Remote(_) => ("remote", "插件返回错误"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RpcSessionDiagnostic { + Ready, + Quiescing, + Failed { + code: &'static str, + message: &'static str, + }, +} + +pub(crate) struct RpcSessionLifecycle { + pub(super) failure: Arc, + end: Option>, +} + +impl RpcSessionLifecycle { + pub(crate) fn new( + failure: impl Fn(Duration) + Send + Sync + 'static, + end: impl FnOnce() + Send + Sync + 'static, + ) -> Self { + Self { + failure: Arc::new(failure), + end: Some(Box::new(end)), + } + } + + pub(super) fn fail(&self, uptime: Duration) { + (self.failure)(uptime); + } +} + +/// 从实际租约与调用表采样,各项存在重叠,不构成同一时刻的可加总分区 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RpcSessionLoad { + /// 总在途租约,包含初次排队、取消确认和 End 后仍由消费者持有的流 + pub(crate) active_calls: usize, + /// 正在占用执行名额的调用,包含取消确认前尚未归还的名额 + pub(crate) executing_calls: usize, + /// 正在占用初次准入排队名额的调用 + pub(crate) queued_calls: usize, + /// 调用表中等待恢复授权的调用 + pub(crate) resuming_calls: usize, + /// 驻留回调名额,包含已回复但插件尚未消费的结果 + pub(crate) pending_callbacks: usize, + /// 已交付首结果且尚未收到 End 或取消的协议响应流 + pub(crate) active_streams: usize, +} + +impl Drop for RpcSessionLifecycle { + fn drop(&mut self) { + if let Some(end) = self.end.take() { + end(); + } + } +} + +/// 不派生 Debug,防止原始请求、凭据或模型输出进入宿主诊断 +pub struct RpcReply { + pub result: serde_json::Value, + pub payload: Vec, +} + +/// 具体回调在领域适配器校验账号、出站与执行身份;上下文只取自宿主在途调用表 +pub trait CallbackHandler: Send + Sync { + /// 在 Call 可见之前建立资源范围;不得执行 I/O 或调用 RPC + fn begin(&self, _context: &CallContext, _deadline: &CallDeadline) {} + + /// 调用结束或取消时立即撤销其句柄;实现必须幂等且不能阻塞 + fn finish(&self, _context: &CallContext) {} + + /// 首个流式结果已到达;资源仍绑定原调用并在终态统一回收 + fn streaming(&self, _context: &CallContext) {} + + fn call( + &self, + context: CallContext, + method: String, + params: serde_json::Value, + payload: Vec, + ) -> BoxFuture<'static, Result>; +} diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs index 959403747..303bf6cc2 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs @@ -11,7 +11,7 @@ use tokio::{ sync::{mpsc, oneshot}, }; -use super::session::{CallbackHandler, RpcError, RpcReply, Shared}; +use super::{CallbackHandler, RpcError, RpcReply, Shared}; pub(super) fn start( writer: W, diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs b/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs index 85acef174..f9c8d2df5 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/mod.rs @@ -1,8 +1,12 @@ //! 插件双向 RPC 会话、调用结果与生命周期的公共入口 mod admission; +mod calls; +mod contract; mod dispatch; mod session; -pub use session::{CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSession}; -pub(crate) use session::{RpcSessionDiagnostic, RpcSessionLifecycle, Shared}; +pub(crate) use calls::Shared; +pub use contract::{CallbackHandler, RpcError, RpcLimits, RpcReply}; +pub(crate) use contract::{RpcSessionDiagnostic, RpcSessionLifecycle, RpcSessionLoad}; +pub use session::RpcSession; diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index 8798c20da..27ba36554 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -1,197 +1,24 @@ -//! 插件 RPC 会话的调用登记、超时取消、故障传播与关闭管理 +//! 插件 RPC 会话的握手、对外调用、准入和进程关闭协调 use std::{ - collections::BTreeMap, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, + sync::{Arc, atomic::Ordering}, time::{Duration, Instant}, }; -use futures::future::BoxFuture; -use gateway_host::process::{ProcessControl, ProcessSpec, ProcessStartError, ProcessSupervisor}; +use gateway_host::process::{ProcessSpec, ProcessStartError, ProcessSupervisor}; use gateway_plugin_sdk::{ - CallBudgetError, CallBudgetSource, CallContext, Frame, Handshake, Message, PROTOCOL_VERSION, - PluginFault, ResolvedCallBudgets, Stage, - client::{CallDeadline, CallDeadlinePause, EncodedFrame, read_frame, write_frame}, -}; -use tokio::sync::{ - Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch, + CallBudgetSource, CallContext, Frame, Handshake, Message, PROTOCOL_VERSION, + ResolvedCallBudgets, Stage, + client::{CallDeadline, EncodedFrame, read_frame, write_frame}, }; +use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore, mpsc}; +use super::{ + CallbackHandler, RpcError, RpcLimits, RpcReply, RpcSessionDiagnostic, RpcSessionLifecycle, + RpcSessionLoad, Shared, dispatch, +}; use crate::{PreparedPackage, RpcStream, stream::StreamIngress}; -use super::{admission::ExecutionCapacity, dispatch}; - -#[derive(Debug, Clone, Copy)] -pub struct RpcLimits { - /// 受管回调与观察队列的缓冲预算,不是请求正文或 IPC 消息总量上限 - pub maximum_buffered_body_bytes: usize, - /// 包含初次排队、下游等待、取消确认和未消费流的总在途上限 - pub maximum_calls: usize, - pub maximum_executing_calls: usize, - pub maximum_queued_calls: usize, - pub maximum_queue_timeout: Duration, - pub maximum_callbacks: usize, - pub handshake_timeout: Duration, - pub maximum_stderr_bytes: usize, -} - -impl Default for RpcLimits { - fn default() -> Self { - Self { - maximum_buffered_body_bytes: 1024 * 1024, - maximum_calls: 256, - maximum_executing_calls: 64, - maximum_queued_calls: 64, - maximum_queue_timeout: Duration::from_secs(30), - maximum_callbacks: 320, - handshake_timeout: Duration::from_secs(5), - maximum_stderr_bytes: 64 * 1024, - } - } -} - -#[derive(Debug, Clone, thiserror::Error)] -pub enum RpcError { - #[error("plugin process could not be started: {0}")] - Start(#[source] ProcessStartError), - #[error("plugin handshake does not match the prepared instance")] - Handshake(#[source] Option), - #[error("plugin call budget negotiation failed: {0}")] - Budget(#[from] CallBudgetError), - #[error("plugin protocol is invalid")] - Protocol(#[source] Option), - #[error("plugin returned an invalid response at {0:?}")] - InvalidResponse(Stage, #[source] Option), - #[error("plugin process or transport has stopped")] - Closed(#[source] Option), - #[error("plugin call exceeded its deadline")] - Timeout, - #[error("plugin session was cancelled")] - Cancelled, - #[error("plugin call capacity is exhausted")] - Capacity, - #[error("plugin admission capacity is exhausted")] - Overloaded, - #[error("plugin call context is invalid")] - Context(#[source] Option), - #[error("plugin returned an error")] - Remote(PluginFault), -} - -impl RpcError { - pub(crate) fn is_overloaded(&self) -> bool { - matches!(self, Self::Overloaded) - || matches!(self, Self::Remote(fault) if fault.code == gateway_plugin_sdk::ErrorCode::Capacity) - } - - /// 会话状态与请求错误共用安全分类,不展开插件返回正文 - pub(crate) fn diagnostic(&self) -> (&'static str, &'static str) { - match self { - RpcError::Start(_) => ("process_start", "插件进程启动失败"), - RpcError::Handshake(_) => ("handshake", "插件握手失败"), - RpcError::Budget(_) => ("call_budget", "插件调用预算协商失败"), - RpcError::Protocol(_) => ("protocol", "插件协议错误"), - RpcError::InvalidResponse(stage, _) => ( - "invalid_response", - match stage { - Stage::Registration => "注册阶段:插件返回了无效响应", - Stage::Configuration => "配置阶段:插件返回了无效响应", - Stage::Authentication => "认证阶段:插件返回了无效响应", - Stage::Routing => "路由阶段:插件返回了无效响应", - Stage::Scheduling => "调度阶段:插件返回了无效响应", - Stage::Retry => "重试阶段:插件返回了无效响应", - Stage::Http => "HTTP 中间件:插件返回了无效响应", - Stage::Service => "服务中间件:插件返回了无效响应", - Stage::WebSocket => "WebSocket 中间件:插件返回了无效响应", - Stage::Request => "请求中间件:插件返回了无效响应", - Stage::Attempt => "尝试中间件:插件返回了无效响应", - Stage::Outbound => "出站准备中间件:插件返回了无效响应", - Stage::Upstream => "上游适配阶段:插件返回了无效响应", - Stage::Observation => "观察阶段:插件返回了无效响应", - Stage::Management | Stage::PublicManagement => "管理阶段:插件返回了无效响应", - Stage::CommandLine => "命令行阶段:插件返回了无效响应", - Stage::Maintenance => "维护阶段:插件返回了无效响应", - }, - ), - RpcError::Closed(_) => ("closed", "插件进程或传输已停止"), - RpcError::Timeout => ("timeout", "插件调用超时"), - RpcError::Cancelled => ("cancelled", "插件会话已取消"), - RpcError::Capacity => ("capacity", "插件调用容量已耗尽"), - RpcError::Overloaded => ("overloaded", "插件暂时繁忙"), - RpcError::Context(_) => ("context", "插件调用上下文无效"), - RpcError::Remote(_) => ("remote", "插件返回错误"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RpcSessionDiagnostic { - Ready, - Quiescing, - Failed { - code: &'static str, - message: &'static str, - }, -} - -pub(crate) struct RpcSessionLifecycle { - failure: Arc, - end: Option>, -} - -impl RpcSessionLifecycle { - pub(crate) fn new( - failure: impl Fn(Duration) + Send + Sync + 'static, - end: impl FnOnce() + Send + Sync + 'static, - ) -> Self { - Self { - failure: Arc::new(failure), - end: Some(Box::new(end)), - } - } - - fn fail(&self, uptime: Duration) { - (self.failure)(uptime); - } -} - -impl Drop for RpcSessionLifecycle { - fn drop(&mut self) { - if let Some(end) = self.end.take() { - end(); - } - } -} - -/// 不派生 Debug,防止原始请求、凭据或模型输出进入宿主诊断 -pub struct RpcReply { - pub result: serde_json::Value, - pub payload: Vec, -} - -/// 具体回调在领域适配器校验账号、出站与执行身份;上下文只取自宿主在途调用表 -pub trait CallbackHandler: Send + Sync { - /// 在 Call 可见之前建立资源范围;不得执行 I/O 或调用 RPC - fn begin(&self, _context: &CallContext, _deadline: &CallDeadline) {} - - /// 调用结束或取消时立即撤销其句柄;实现必须幂等且不能阻塞 - fn finish(&self, _context: &CallContext) {} - - /// 首个流式结果已到达;资源仍绑定原调用并在终态统一回收 - fn streaming(&self, _context: &CallContext) {} - - fn call( - &self, - context: CallContext, - method: String, - params: serde_json::Value, - payload: Vec, - ) -> BoxFuture<'static, Result>; -} - pub struct RpcSession { shared: Arc, handshake: Handshake, @@ -216,473 +43,6 @@ impl RpcSession { } } -pub(crate) struct Shared { - state: Mutex, - started_at: Instant, - expected_stop: AtomicBool, - failure_observer: Option>, - pub control: mpsc::Sender, - pub process: ProcessControl, - pub stopped: watch::Sender, - callback_slots: Arc, - execution: ExecutionCapacity, - cancellations_changed: Notify, -} - -struct State { - failure: Option, - calls: BTreeMap, -} - -struct PendingCall { - _slot: Arc, - execution: Option, - deadline: CallDeadline, - handler: Arc, - transmitted: bool, - cancellation: Option, - context: CallContext, - result: Option>>, - stream: Option, - scheduling: Option, - callbacks: BTreeMap, -} - -// 调度状态只属于父任务;回调完成与恢复授权分别推进 -enum Scheduling { - Running { - epoch: u64, - }, - Suspended { - epoch: u64, - pause: Option, - }, - Resuming { - epoch: u64, - pause: Option, - task: tokio::task::AbortHandle, - }, -} - -struct CallbackTask { - task: tokio::task::AbortHandle, - pauses_budget: bool, - _permit: OwnedSemaphorePermit, -} - -impl Drop for CallbackTask { - fn drop(&mut self) { - self.task.abort(); - } -} - -impl PendingCall { - fn stop_resumption(&mut self) { - if let Some(Scheduling::Resuming { task, .. }) = self.scheduling.take() { - task.abort(); - } - } - - fn finish(mut self, result: Result) { - self.callbacks.clear(); - self.stop_resumption(); - let stream = self.stream.take(); - let sender = self.result.take(); - // 先同步关闭调用资源,Core 才能在收到结果时看到全部发送副作用 - drop(self); - if let Some(stream) = stream { - stream.finish(result.as_ref().map(|_| ()).map_err(Clone::clone)); - } - if let Some(sender) = sender { - let _ = sender.send(result); - } - } -} - -impl Drop for PendingCall { - fn drop(&mut self) { - self.handler.finish(&self.context); - } -} - -impl Shared { - fn state(&self) -> std::sync::MutexGuard<'_, State> { - self.state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - pub fn fail(&self, error: RpcError) { - let uptime = self.started_at.elapsed(); - let (calls, failure_observer) = { - let mut state = self.state(); - if state.failure.is_some() { - return; - } - state.failure = Some(error.clone()); - let observer = (!self.expected_stop.load(Ordering::Acquire)) - .then(|| self.failure_observer.clone()) - .flatten(); - (std::mem::take(&mut state.calls), observer) - }; - self.execution.close(); - for call in calls.into_values() { - call.finish(Err(error.clone())); - } - if let Some(observer) = failure_observer { - observer(uptime); - } - self.stopped.send_replace(true); - self.process.stop(); - self.cancellations_changed.notify_waiters(); - } - - fn expect_stop(&self) { - self.expected_stop.store(true, Ordering::Release); - } - - pub fn finish(&self, id: u64, result: Result) -> Result<(), RpcError> { - let mut state = self.state(); - let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?; - if call.cancellation.is_some() { - return Ok(()); - } - if matches!(&result, Err(RpcError::Remote(fault)) if fault.code == gateway_plugin_sdk::ErrorCode::Timeout) - { - log_timeout(&call.context, "plugin", "initial_reply", "remote_timeout"); - } - if result.is_ok() && !matches!(call.scheduling, Some(Scheduling::Running { .. })) { - return Err(RpcError::Protocol(None)); - } - if call.stream.is_some() && result.is_ok() { - if call.context.resource_stream { - call.deadline.clear(); - } - let initial = call.result.take().ok_or(RpcError::Protocol(None))?; - call.execution.take(); - call.scheduling.take(); - call.handler.streaming(&call.context); - let _ = initial.send(result); - } else { - let pending = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?; - drop(state); - pending.finish(result); - } - Ok(()) - } - - pub fn stream_chunk(&self, id: u64, sequence: u64, payload: Vec) -> Result<(), RpcError> { - let mut state = self.state(); - let call = state.calls.get_mut(&id).ok_or(RpcError::Protocol(None))?; - if call.cancellation.is_some() { - return Ok(()); - } - if call.result.is_some() { - return Err(RpcError::Protocol(None)); - } - call.stream - .as_mut() - .ok_or(RpcError::Protocol(None))? - .receive(sequence, payload) - } - - pub fn stream_end(&self, id: u64, error: Option) -> Result<(), RpcError> { - let mut state = self.state(); - if state - .calls - .get(&id) - .is_some_and(|call| call.cancellation.is_some()) - { - return Ok(()); - } - let call = state.calls.remove(&id).ok_or(RpcError::Protocol(None))?; - drop(state); - if call.stream.is_none() || call.result.is_some() { - call.finish(Err(RpcError::Protocol(None))); - return Err(RpcError::Protocol(None)); - } - let result = error.map_or_else( - || { - Ok(RpcReply { - result: serde_json::Value::Null, - payload: Vec::new(), - }) - }, - |error| Err(RpcError::Remote(error)), - ); - call.finish(result); - Ok(()) - } - - pub fn release_stream_credit(&self, id: u64, bytes: u32) -> Result<(), RpcError> { - let mut state = self.state(); - // 完成后仍可消费已排队数据,无须再给已关闭的流发信用 - let Some(call) = state.calls.get_mut(&id) else { - return Ok(()); - }; - call.stream - .as_mut() - .ok_or(RpcError::Protocol(None))? - .release(bytes)?; - drop(state); - self.send_control(Frame::control(Message::Credit { - id, - bytes, - frames: 1, - })); - Ok(()) - } - - pub fn context(&self, id: u64) -> Option { - self.state() - .calls - .get(&id) - .filter(|call| call.cancellation.is_none()) - .map(|call| call.context.clone()) - } - - pub fn is_cancelling(&self, id: u64) -> bool { - self.state() - .calls - .get(&id) - .is_some_and(|call| call.cancellation.is_some()) - } - - pub fn mark_transmitted(&self, id: u64) -> bool { - let mut state = self.state(); - let Some(call) = state.calls.get_mut(&id) else { - return false; - }; - if call.cancellation.is_some() { - return false; - } - call.transmitted = true; - true - } - - /// 先撤销调用权限,再等待对端确认;只有无响应对端才终止整个 incarnation - pub fn cancel(self: &Arc, id: u64, error: RpcError) { - let mut state = self.state(); - let Some(call) = state.calls.get_mut(&id) else { - return; - }; - if call.cancellation.is_some() { - return; - } - if matches!(error, RpcError::Timeout) { - log_timeout( - &call.context, - "runtime", - if call.result.is_some() { - "initial_reply" - } else { - "response_stream" - }, - "processing_budget_exhausted", - ); - } - if !call.transmitted { - if let Some(call) = state.calls.remove(&id) { - call.finish(Err(error)); - } - return; - } - call.cancellation = Some(error.clone()); - call.handler.finish(&call.context); - call.callbacks.clear(); - call.stop_resumption(); - if let Some(result) = call.result.take() { - let _ = result.send(Err(error.clone())); - } - if let Some(stream) = call.stream.take() { - stream.finish(Err(error.clone())); - } - drop(state); - self.send_control(Frame::control(Message::Cancel { id })); - let shared = Arc::clone(self); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(250)).await; - if shared.is_cancelling(id) { - shared.fail(error); - } - }); - } - - pub fn acknowledge_cancellation(&self, id: u64) -> Result<(), RpcError> { - let mut state = self.state(); - if !state - .calls - .get(&id) - .is_some_and(|call| call.cancellation.is_some()) - { - return Err(RpcError::Protocol(None)); - } - state.calls.remove(&id); - drop(state); - self.cancellations_changed.notify_waiters(); - Ok(()) - } - - pub fn track_callback( - &self, - parent_id: u64, - id: u64, - method: &str, - task: tokio::task::AbortHandle, - permit: OwnedSemaphorePermit, - ) -> bool { - let mut state = self.state(); - let Some(call) = state - .calls - .get_mut(&parent_id) - .filter(|call| call.cancellation.is_none()) - else { - task.abort(); - return false; - }; - let pauses_budget = method == gateway_plugin_sdk::call::middleware::NEXT_METHOD; - if !pauses_budget - && let Some(Scheduling::Suspended { pause, .. } | Scheduling::Resuming { pause, .. }) = - call.scheduling.as_mut() - { - pause.take(); - } - call.callbacks.insert( - id, - CallbackTask { - task, - pauses_budget, - _permit: permit, - }, - ); - true - } - - pub(super) fn consume_callback(&self, parent_id: u64, id: u64) -> Result<(), RpcError> { - if id == 0 || !id.is_multiple_of(2) || parent_id == 0 || parent_id.is_multiple_of(2) { - return Err(RpcError::Protocol(None)); - } - // 取消确认可以与消费确认交错;确认只能撤销资源,不能重新创建调用 - if let Some(call) = self.state().calls.get_mut(&parent_id) { - call.callbacks.remove(&id); - } - Ok(()) - } - - pub(super) fn suspend(&self, id: u64, epoch: u64) -> Result<(), RpcError> { - if id == 0 || id.is_multiple_of(2) || epoch == 0 { - return Err(RpcError::Protocol(None)); - } - let mut state = self.state(); - let Some(call) = state.calls.get_mut(&id) else { - return Ok(()); - }; - if call.cancellation.is_some() { - return Ok(()); - } - match call.scheduling.as_ref() { - Some(Scheduling::Running { epoch: previous }) - if previous.checked_add(1) == Some(epoch) => {} - _ => return Err(RpcError::Protocol(None)), - } - let pauses = !call.callbacks.is_empty() - && call - .callbacks - .values() - .all(|callback| callback.pauses_budget); - let pause = pauses.then(|| call.deadline.pause()).flatten(); - call.execution.take(); - call.scheduling = Some(Scheduling::Suspended { epoch, pause }); - Ok(()) - } - - pub(super) fn request_resume(self: &Arc, id: u64, epoch: u64) -> Result<(), RpcError> { - if id == 0 || id.is_multiple_of(2) || epoch == 0 { - return Err(RpcError::Protocol(None)); - } - let mut state = self.state(); - let Some(call) = state.calls.get_mut(&id) else { - return Ok(()); - }; - if call.cancellation.is_some() { - return Ok(()); - } - if !matches!(call.scheduling, Some(Scheduling::Suspended { epoch: current, .. }) if current == epoch) - { - return Err(RpcError::Protocol(None)); - } - let Some(Scheduling::Suspended { pause, .. }) = call.scheduling.take() else { - return Err(RpcError::Protocol(None)); - }; - let shared = Arc::clone(self); - let (ready, started) = oneshot::channel(); - let task = tokio::spawn(async move { - if started.await.is_err() { - return; - } - match shared.execution.resume().await { - Ok(permit) => shared.grant_resume(id, epoch, permit), - Err(error) => shared.cancel(id, error), - } - }); - call.scheduling = Some(Scheduling::Resuming { - epoch, - pause, - task: task.abort_handle(), - }); - let _ = ready.send(()); - Ok(()) - } - - fn grant_resume(&self, id: u64, epoch: u64, permit: OwnedSemaphorePermit) { - let mut state = self.state(); - let Some(call) = state - .calls - .get_mut(&id) - .filter(|call| call.cancellation.is_none()) - else { - return; - }; - if !matches!(call.scheduling, Some(Scheduling::Resuming { epoch: current, .. }) if current == epoch) - { - return; - } - call.execution = Some(permit); - call.scheduling = Some(Scheduling::Running { epoch }); - drop(state); - self.send_control(Frame::control(Message::Resumed { id, epoch })); - } - - pub fn send_control(&self, frame: Frame) { - let Ok(frame) = EncodedFrame::new(frame) else { - self.fail(RpcError::Protocol(None)); - return; - }; - self.send_encoded_control(frame); - } - - pub(super) fn send_encoded_control(&self, frame: EncodedFrame) { - if self.control.try_send(frame).is_err() { - self.fail(RpcError::Capacity); - } - } - - pub(super) fn try_callback_slot(&self) -> Option { - self.callback_slots.clone().try_acquire_owned().ok() - } - - async fn wait_for_callbacks(&self, limits: RpcLimits) { - if let Ok(permits) = self - .callback_slots - .clone() - .acquire_many_owned(limits.maximum_callbacks as u32) - .await - { - drop(permits); - } - } -} - impl RpcSession { pub fn budget(&self, stage: Stage) -> Duration { self.budgets.budget(stage) @@ -693,27 +53,9 @@ impl RpcSession { self.budgets.source(stage) } - /// 替换维护任务前等待已取消的调用确认;未确认仍沿用会话的有界故障回收 + /// 替换维护任务前确认旧调用已取消或所属进程退出 pub(crate) async fn settle_cancellations(&self) { - let ids: Vec<_> = self - .shared - .state() - .calls - .iter() - .filter_map(|(id, call)| call.cancellation.is_some().then_some(*id)) - .collect(); - loop { - let changed = self.shared.cancellations_changed.notified(); - tokio::pin!(changed); - changed.as_mut().enable(); - if !ids.iter().any(|id| self.shared.is_cancelling(*id)) { - break; - } - changed.await; - } - if !self.is_ready() { - self.shared.process.exited().await; - } + self.shared.settle_cancellations().await; } pub async fn start( @@ -862,29 +204,14 @@ impl RpcSession { let (data, data_receiver) = mpsc::channel(limits.maximum_calls); let (control, control_receiver) = mpsc::channel(limits.maximum_callbacks + 2 * limits.maximum_calls + 8); - let (stopped, _) = watch::channel(false); - let callback_slots = Arc::new(Semaphore::new(limits.maximum_callbacks)); - let shared = Arc::new(Shared { - state: Mutex::new(State { - failure: None, - calls: BTreeMap::new(), - }), - started_at: Instant::now(), - expected_stop: AtomicBool::new(false), - failure_observer: lifecycle + let shared = Arc::new(Shared::new( + limits, + process, + control, + lifecycle .as_ref() .map(|lifecycle| Arc::clone(&lifecycle.failure)), - control, - process, - stopped, - callback_slots, - execution: ExecutionCapacity::new( - limits.maximum_executing_calls, - limits.maximum_queued_calls, - limits.maximum_queue_timeout, - ), - cancellations_changed: Notify::new(), - }); + )); dispatch::start( input, output, @@ -919,23 +246,17 @@ impl RpcSession { #[must_use] pub fn is_ready(&self) -> bool { + self.shared.failure_diagnostic().is_none() + } + + pub(crate) fn load(&self) -> RpcSessionLoad { self.shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .failure - .is_none() + .load(self.limits, self.slots.available_permits()) } /// 仅返回稳定分类与固定文案,绝不展开插件 fault、stderr 或请求内容 pub(crate) fn diagnostic(&self) -> RpcSessionDiagnostic { - let state = self - .shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(error) = &state.failure { - let (code, message) = error.diagnostic(); + if let Some((code, message)) = self.shared.failure_diagnostic() { return RpcSessionDiagnostic::Failed { code, message }; } if self.slots.is_closed() { @@ -980,45 +301,6 @@ impl RpcSession { Ok(Duration::from_millis(context.timeout_ms)) } - fn begin_call( - &self, - context: CallContext, - deadline: tokio::time::Instant, - stream: Option, - slot: Arc, - execution: OwnedSemaphorePermit, - ) -> Result { - let id = context.call_id; - let (result, received) = oneshot::channel(); - let mut state = self.shared.state(); - if let Some(error) = &state.failure { - return Err(error.clone()); - } - let deadline = CallDeadline::new(deadline); - self.callbacks.begin(&context, &deadline); - state.calls.insert( - id, - PendingCall { - _slot: slot, - execution: Some(execution), - deadline: deadline.clone(), - handler: self.callbacks.clone(), - transmitted: false, - cancellation: None, - context, - result: Some(result), - stream, - scheduling: Some(Scheduling::Running { epoch: 0 }), - callbacks: BTreeMap::new(), - }, - ); - Ok(StartedCall { - id, - deadline, - received, - }) - } - async fn send_call( &self, method: &str, @@ -1036,7 +318,19 @@ impl RpcSession { RpcError::Overloaded } })?); - let execution = self.shared.execution.enter().await?; + let queued_at = Instant::now(); + let execution = self.shared.execution.enter().await; + tracing::debug!( + event = "plugin.rpc.admission", + instance_id = context.instance_id, + stage = ?context.stage, + request_id = context.request_id, + attempt_id = context.attempt_id, + wait_ms = queued_at.elapsed().as_millis() as u64, + outcome = execution.as_ref().map_or_else(|error| error.diagnostic().0, |_| "admitted"), + "插件 RPC 执行准入完成" + ); + let execution = execution?; let deadline = tokio::time::Instant::now() + timeout; // FIFO 锁同时拥有 ID 与 data 入队顺序;Call/初始 Credit 入队后必须立即释放, // 不能把插件回复或重入回调纳入串行区,也不能为等待锁重置调用期限 @@ -1060,7 +354,14 @@ impl RpcSession { let frame = EncodedFrame::new(frame).map_err(|source| { RpcError::Context(Some(gateway_core::error::ErrorSource::new(source))) })?; - let started = self.begin_call(context, deadline, stream, slot.clone(), execution)?; + let started = self.shared.begin_call( + context, + deadline, + stream, + slot.clone(), + execution, + self.callbacks.clone(), + )?; let mut guard = CallGuard { shared: Arc::clone(&self.shared), id: started.id, @@ -1234,36 +535,9 @@ impl Drop for CallGuard { } } -struct StartedCall { - id: u64, - deadline: CallDeadline, - received: oneshot::Receiver>, -} - struct CompletedCall { slot: Arc, id: u64, deadline: CallDeadline, reply: RpcReply, } - -fn log_timeout( - context: &CallContext, - owner: &'static str, - phase: &'static str, - reason: &'static str, -) { - tracing::warn!( - event = "plugin.rpc.timeout", - owner, - phase, - instance_id = context.instance_id, - call_id = context.call_id, - request_id = context.request_id, - attempt_id = context.attempt_id, - stage = ?context.stage, - budget_ms = context.timeout_ms, - reason, - "插件 RPC 调用超时" - ); -} diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/dispatch.rs b/backend/crates/gateway-plugin/runtime/tests/callback/dispatch.rs new file mode 100644 index 000000000..892941ef7 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/callback/dispatch.rs @@ -0,0 +1,72 @@ +//! 验证回调方法精确分派,未知方法不进入其他能力的授权或资源处理 + +use gateway_admin::{ + model::plugins::management::PluginManagementRequest, ports::plugin_management::PluginManagement, +}; +use serde_json::{Value, json}; + +#[tokio::test] +async fn unknown_callback_methods_are_unsupported_without_affecting_known_methods() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment.plugin(json!({ + "management_registration":{"routes":[{"method":"GET","path":"dispatch","request_content_types":[],"response_content_types":["application/json"]}]}, + "data_queries":[ + {"method":"host.unknown","query":{}}, + {"method":"host.middleware.unknown","query":{}}, + {"method":"host.upstream.unknown","query":{}}, + {"method":"host.http.unknown","query":{}}, + {"method":gateway_plugin_sdk::call::host::HTTP_DO,"query":{}}, + {"method":gateway_plugin_sdk::call::middleware::NEXT_METHOD,"query":{}} + ] + })).await; + let generation = core + .snapshots() + .acquire() + .unwrap() + .extensions() + .unwrap() + .clone(); + let view = runtime.views(&generation).await.unwrap().remove(0); + let response = runtime + .handle( + &generation, + &view.target, + PluginManagementRequest { + request_context: None, + headers: Vec::new(), + method: "GET".into(), + path: "dispatch".into(), + query: String::new(), + content_type: None, + body: Vec::new(), + request_id: "callback-dispatch".into(), + }, + ) + .await + .unwrap(); + let results: Vec = serde_json::from_slice(&response.body).unwrap(); + let codes: Vec<_> = results + .iter() + .map(|result| result["error"].as_str().unwrap()) + .collect(); + assert_eq!( + codes, + [ + "unsupported", + "unsupported", + "unsupported", + "unsupported", + "invalid_input", + "permission_denied" + ] + ); + drop(generation); + environment.release_plugin_accounts(&runtime); + runtime.shutdown().await; + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs b/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs index 52484946b..04fa4e0a6 100644 --- a/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs @@ -1,5 +1,6 @@ //! 插件宿主回调的测试入口 +mod dispatch; mod key_facts; mod keys; mod log; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs index 77f1a53fc..0175c87e1 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs @@ -881,6 +881,74 @@ async fn invalid_client_key_and_group_scopes_are_rejected_before_publish() { } } +#[tokio::test] +async fn observer_call_load_tracks_the_active_lease_until_completion() { + use gateway_admin::ports::plugins::PluginRuntimeDiagnostics; + + let markers = tempfile::tempdir().unwrap(); + let started = markers.path().join("started.jsonl"); + let completed = markers.path().join("completed.jsonl"); + let (cache, store, runtime) = setup( + vec![( + "tracked", + serde_json::json!({ + "observation_started_marker": started, + "observation_marker": completed, + "observation_delay_ms": 200, + }), + vec![binding(EventKind::RequestCompleted, 0)], + )], + RpcLimits::default(), + ) + .await; + let snapshot = store.snapshot.lock().unwrap().clone(); + let generation = gateway_core::routing::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + plan.dispatch( + generation.clone(), + observation("req_tracked", RequestObservationOutcome::Succeeded) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + wait_for_lines(&started, 1).await; + let read_load = || async { + runtime + .runtime_diagnostics(&snapshot, Some(1), Some(&generation)) + .await + .unwrap() + .remove("tracked") + .unwrap() + .load + .expect("published session has a load snapshot") + }; + let load = read_load().await; + assert_eq!(load.active_calls, 1); + assert_eq!(load.executing_calls, 1); + assert_eq!(load.queued_calls, 0); + assert_eq!(load.pending_callbacks, 0); + assert_eq!(load.active_streams, 0); + + wait_for_lines(&completed, 1).await; + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let load = read_load().await; + if load.active_calls == 0 && load.executing_calls == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("completed calls release their observed resource occupancy"); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + #[tokio::test] async fn observer_backpressure_drops_excess_and_inflight_dispatch_keeps_generation_alive() { let marker_directory = tempfile::tempdir().unwrap(); diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs index fcf3bbfa6..4d2975ac8 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/diagnostics.rs @@ -2,7 +2,9 @@ use super::wait_until_unready; use gateway_admin::model::Revision; -use gateway_admin::model::plugins::instances::PluginInstanceRuntimeStatus; +use gateway_admin::model::plugins::instances::{ + PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus, +}; use gateway_admin::ports::plugins::PluginPreparation; use gateway_core::routing::ConfigRevision; use gateway_core::routing::extensions::ExtensionPreparationPort; @@ -31,6 +33,17 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat Some(snapshot.instances[0].artifact_sha256.as_str()) ); assert!(running.failure.is_none()); + assert_eq!( + running.load, + Some(PluginInstanceRuntimeLoad { + active_calls: 0, + executing_calls: 0, + queued_calls: 0, + resuming_calls: 0, + pending_callbacks: 0, + active_streams: 0, + }) + ); let mut failed = snapshot.clone(); failed.config_revision = Revision::new(2).unwrap(); @@ -56,6 +69,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat ); assert!(failed_runtime.failure.is_some()); assert_eq!(failed_runtime.actual_revision, Some(1)); + assert_eq!(failed_runtime.load, running.load); let mut disabled = failed; disabled.config_revision = Revision::new(3).unwrap(); @@ -105,6 +119,7 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() { diagnostics["instance-one"].status, PluginInstanceRuntimeStatus::Preparing ); + assert!(diagnostics["instance-one"].load.is_none()); let candidate = preparation.await.expect("delayed candidate"); drop(candidate); diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/calls.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/calls.rs new file mode 100644 index 000000000..dd50df34e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/calls.rs @@ -0,0 +1,54 @@ +//! 验证调用表在成功、远端错误和超时的结果交付前同步撤销回调资源 + +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use gateway_plugin_runtime::{RpcError, RpcLimits, RpcSession}; +use gateway_plugin_sdk::Stage; +use serde_json::json; + +use super::session::{LifecycleCallbacks, prepared_session}; + +#[tokio::test] +async fn terminal_results_revoke_callback_resources_before_waking_callers() { + for method in ["echo", "deny", "hang"] { + let (_cache, prepared, handshake, processes) = prepared_session(); + let callbacks = Arc::new(LifecycleCallbacks::default()); + let session = RpcSession::start( + prepared, + handshake, + RpcLimits::default(), + &processes, + callbacks.clone(), + ) + .await + .unwrap(); + let timeout = if method == "hang" { + Duration::from_millis(50) + } else { + Duration::from_secs(2) + }; + let result = session + .call( + method, + session.context(Stage::Request, timeout), + json!({}), + Vec::new(), + ) + .await; + match method { + "echo" => assert!(result.is_ok()), + "deny" => assert!(matches!(result, Err(RpcError::Remote(_)))), + "hang" => assert!(matches!(result, Err(RpcError::Timeout))), + _ => unreachable!(), + } + assert_eq!(callbacks.begun.load(Ordering::Relaxed), 1); + assert!( + callbacks.finished.load(Ordering::Relaxed) >= 1, + "调用结果已经交付,资源权限必须先同步撤销:{method}" + ); + session.shutdown(Duration::from_secs(1)).await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs index 43a5aadf1..9babb9b6c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs @@ -1,5 +1,6 @@ //! 插件双向 RPC 分派与会话生命周期的测试入口 mod admission; +mod calls; mod dispatch; mod session; diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs index d438c0eab..56ccfb286 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs @@ -31,7 +31,7 @@ struct BlockingCallbacks { #[derive(Default)] pub(super) struct LifecycleCallbacks { pub(super) begun: AtomicUsize, - finished: AtomicUsize, + pub(super) finished: AtomicUsize, } struct OversizedReplyCallbacks; diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index e2af86b04..45838794f 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -570,7 +570,7 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? .build()?; ``` -按 binding 顺序、插件 ID、实例 ID 确定委托链,首个非委托决定交给 Core 处理。整条链最多等待 2 秒,并受请求剩余期限约束。 +按 binding 顺序、插件 ID、实例 ID 确定委托链,首个非委托决定交给 Core 处理。整条链共享匹配实例中最大的重试阶段预算,并受请求剩余期限约束;单个实例同时受自身声明预算约束。未声明时使用默认的 2 秒,预算规则见 [并发与期限](../README.md#并发与期限)。 退出、超时或无法解码的决定委托后续处理,最终回到宿主决定;额外字段忽略,故障不改写原始上游错误。 middleware 的 `next` 最多调用一次,重试与额外模型执行通过独立调用创建 diff --git a/backend/crates/gateway-plugin/sdk/src/call/host.rs b/backend/crates/gateway-plugin/sdk/src/call/host.rs index 008910b47..fa94c0e76 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/host.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/host.rs @@ -1,10 +1,30 @@ -//! 宿主回调的数据合同;这里只传递数据,不持有宿主服务或执行资源 +//! 宿主回调的方法名与数据合同;不持有宿主服务或执行资源 use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use super::model::ExecutionEvent; +pub const LOG: &str = "host.log"; +pub const STATE_GET: &str = "host.state.get"; +pub const STATE_PUT: &str = "host.state.put"; +pub const STATE_DELETE: &str = "host.state.delete"; +pub const KEYS_LIST: &str = "host.keys.list"; +pub const MODELS_LIST: &str = "host.models.list"; +pub const MODEL_EXECUTE: &str = "host.model.execute"; +pub const MODEL_EXECUTE_STREAM: &str = "host.model.execute_stream"; +pub const MODEL_STREAM_READ: &str = "host.model.stream_read"; +pub const MODEL_STREAM_CLOSE: &str = "host.model.stream_close"; +pub const AFFINITY_LOOKUP: &str = "host.affinity.lookup"; +pub const AUTH_LIST: &str = "host.auth.list"; +pub const AUTH_GET: &str = "host.auth.get"; +pub const AUTH_GET_RUNTIME: &str = "host.auth.get_runtime"; +pub const AUTH_SAVE: &str = "host.auth.save"; +pub const HTTP_DO: &str = "host.http.do"; +pub const HTTP_DO_STREAM: &str = "host.http.do_stream"; +pub const HTTP_STREAM_READ: &str = "host.http.stream_read"; +pub const HTTP_STREAM_CLOSE: &str = "host.http.stream_close"; + const MODEL_EVENT_BATCH_PREFIX: [u8; 4] = *b"HME1"; const MAX_MODEL_EVENT_BATCH_BYTES: usize = 8 * 1024 * 1024; diff --git a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs index a39e073fc..690396f8e 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs @@ -9,6 +9,15 @@ mod codec; pub const REGISTER_METHOD: &str = "upstream_adapter.register"; pub const EXECUTE_METHOD: &str = "upstream_adapter.execute"; +pub const HTTP_DO: &str = "host.upstream.http.do"; +pub const HTTP_DO_STREAM: &str = "host.upstream.http.do_stream"; +pub const HTTP_STREAM_READ: &str = "host.upstream.http.stream_read"; +pub const HTTP_STREAM_CLOSE: &str = "host.upstream.http.stream_close"; +pub const WEBSOCKET_OPEN: &str = "host.upstream.websocket.open"; +pub const WEBSOCKET_SEND: &str = "host.upstream.websocket.send"; +pub const WEBSOCKET_READ: &str = "host.upstream.websocket.read"; +pub const WEBSOCKET_CLOSE: &str = "host.upstream.websocket.close"; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum BuiltinProvider { diff --git a/backend/crates/gateway-plugin/sdk/src/client/http.rs b/backend/crates/gateway-plugin/sdk/src/client/http.rs index d260709ce..1b63a3f48 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/http.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/http.rs @@ -5,7 +5,10 @@ use serde::{Serialize, de::DeserializeOwned}; use super::{HostClient, HostReply, SessionError, read::PendingRead}; use crate::{ ErrorCode, PluginFault, - call::{host as wire, upstream_adapter::UpstreamHttpRequest}, + call::{ + host as wire, + upstream_adapter::{self, UpstreamHttpRequest}, + }, }; /// 响应头与惰性正文;不包含宿主的内部流句柄 @@ -35,7 +38,7 @@ impl HostClient { request: wire::HttpRequest, body: Vec, ) -> Result { - self.open_http("host.http.do_stream", request, body, false) + self.open_http(wire::HTTP_DO_STREAM, request, body, false) .await } @@ -62,7 +65,7 @@ impl HostClient { request: UpstreamHttpRequest, body: Vec, ) -> Result { - self.open_http("host.upstream.http.do_stream", request, body, true) + self.open_http(upstream_adapter::HTTP_DO_STREAM, request, body, true) .await } @@ -124,9 +127,9 @@ impl HostHttpBody { let host = self.host.clone(); let params = serde_json::json!({"stream": stream, "maximum_bytes": maximum_bytes}); let method = if self.upstream { - "host.upstream.http.stream_read" + upstream_adapter::HTTP_STREAM_READ } else { - "host.http.stream_read" + wire::HTTP_STREAM_READ }; async move { host.call(method, params, vec![]).await } }) @@ -183,9 +186,9 @@ impl HostHttpBody { empty_callback( &self.host, if self.upstream { - "host.upstream.http.stream_close" + upstream_adapter::HTTP_STREAM_CLOSE } else { - "host.http.stream_close" + wire::HTTP_STREAM_CLOSE }, wire::StreamClose { stream: stream.clone(), diff --git a/backend/crates/gateway-plugin/sdk/src/client/keys.rs b/backend/crates/gateway-plugin/sdk/src/client/keys.rs index bc3feb2d8..da3eda10a 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/keys.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/keys.rs @@ -3,7 +3,7 @@ use crate::{ ErrorCode, PluginFault, call::{ - host::{KeyListRequest, KeyListResult}, + host::{KEYS_LIST, KeyListRequest, KeyListResult}, key_budgets, }, }; @@ -41,7 +41,7 @@ impl HostClient { let invalid = || PluginFault::new(ErrorCode::InvalidInput, "invalid key list payload"); let reply = self .call( - "host.keys.list", + KEYS_LIST, serde_json::to_value(query).map_err(|_| invalid())?, Vec::new(), ) diff --git a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs index 6ac15c48a..503f346e5 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/upstream_adapter.rs @@ -12,7 +12,7 @@ use crate::{ call::{ host::HttpResponse, upstream_adapter::{ - UpstreamWebSocketMessage, UpstreamWebSocketRead, UpstreamWebSocketRequest, + self, UpstreamWebSocketMessage, UpstreamWebSocketRead, UpstreamWebSocketRequest, WebSocketMessageKind, }, }, @@ -54,7 +54,7 @@ impl HostClient { request: UpstreamWebSocketRequest, ) -> Result { let (response, body): (HttpResponse, _) = - callback(self, "host.upstream.websocket.open", request, vec![]).await?; + callback(self, upstream_adapter::WEBSOCKET_OPEN, request, vec![]).await?; if response.stream.is_some() || (response.status == 101 && !body.is_empty()) { return Err(invalid()); } @@ -94,7 +94,7 @@ impl UpstreamWebSocket { } empty_callback( &self.host, - "host.upstream.websocket.send", + upstream_adapter::WEBSOCKET_SEND, UpstreamWebSocketMessage { kind }, body, ) @@ -119,7 +119,7 @@ impl UpstreamWebSocket { let host = self.host.clone(); async move { host.call( - "host.upstream.websocket.read", + upstream_adapter::WEBSOCKET_READ, serde_json::json!({}), vec![], ) @@ -150,7 +150,7 @@ impl UpstreamWebSocket { .get_or_try_init(|| async { empty_callback( &self.host, - "host.upstream.websocket.close", + upstream_adapter::WEBSOCKET_CLOSE, serde_json::json!({}), vec![], ) diff --git a/docs/api.md b/docs/api.md index fe8a4cd60..f9be45736 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2579,7 +2579,28 @@ CLI 插件也须在具体调用中选择当前 Key。管理员身份本身不提 | --- | --- | | `enabled` | 期望启用状态 | | `publishedRevision` | 当前已发布的全局配置版本 | -| `running` | 实例已启用且发布版本与持久化配置一致;不代表进程存活或外部服务健康 | +| `running` | `runtime.status == "running"` 的派生值;不代表插件业务或外部服务健康 | +| `runtime.status` | 当前进程观察到的运行状态,独立于期望启用和发布版本 | +| `runtime.actualRevision` / `actualArtifactSha256` | 当前发布集合持有的实例版本与制品摘要,无对应会话时为 `null` | +| `runtime.failure` | 脱敏后的 `{ code, message }`;没有已知失败时为 `null` | +| `runtime.drainingRevisions` | 仍被在途工作持有的旧实例版本 | +| `runtime.load` | 当前发布会话的资源采样;无对应会话或诊断不可用时为 `null` | + +`runtime.status` 包括 `disabled`、`awaiting_publication`、`preparing`、`running`、`blocked`、 +`preparation_failed`、`faulted`、`draining` 和 `unknown`。`running` 要求配置匹配、当前会话就绪且发布集合可服务。 +已启用实例缺少运行诊断时返回 `unknown`,不根据发布版本推断运行正常,也不制造失败原因;已停用配置仍可确定为 `disabled` + +`runtime.load` 与 `actualRevision`、`actualArtifactSha256` 指向同一会话,不汇总其他排空版本。 +以下计数来自现有调用表和容量资源,采样不是原子快照,各项可能重叠,不能相加作为调用总数: + +| 字段 | 含义 | +| --- | --- | +| `activeCalls` | 总在途名额,包含排队、等待取消确认及仍由响应流持有的调用 | +| `executingCalls` | 首结果前占用的执行名额 | +| `queuedCalls` | 等待首次执行的名额 | +| `resumingCalls` | 已挂起、正在等待恢复授权的调用 | +| `pendingCallbacks` | 驻留回调,包含已返回但插件尚未消费的结果 | +| `activeStreams` | 已交付首结果、尚未收到 End 的流;End 后仍待消费的缓冲继续计入 `activeCalls` | ### 12.2 管理扩展页面与原始 API diff --git a/docs/architecture.md b/docs/architecture.md index 926fafe63..a41b3445a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -155,6 +155,8 @@ flowchart LR 进程由最后一个实例持有者释放时关闭,弱索引不延长寿命。共享进程保留原握手身份,各次调用仍携带所属请求的冻结计划;显式排空按实例、摘要与 revision 定位 - **发布能力显式实现**:Admin 分别定义准备、运行诊断和状态生命周期端口,完整发布实现必须满足全部合同。 诊断可以明确返回不可用;状态激活、实例排空与迁移必须由实现显式处理,不以缺省空操作或静默成功代替 +- **控制面查询不持有变更资源**:实例查询、静态配置校验、变更与迁移编排分别归属 `instances` 的子模块;提交后发布与迁移恢复共用 `publication` 收尾。 + 包检查缓存只保存按不可变制品摘要产生的静态结果,加载与检查不持有缓存锁,暂时性错误不进入缓存 - **插件故障按实例隔离**:恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布; 用户正在修改的实例仍须准备成功才能提交。集合区分进程就绪、可继续服务与需要后台重建,单个进程退出不撤销全部请求快照。 能力响应无法解码或消费时只结束所属调用,不连带停止共享进程;帧封装、调用关联或传输损坏才终止 RPC 会话。 @@ -223,6 +225,10 @@ flowchart LR #### 调用资源与状态 +- **会话只维护一份调用所有权**:RPC `session` 提供调用入口,`calls` 持有唯一调用表、准入转换与终结资源,`dispatch` 负责传输。 + 回调入口分别组装原生端口、登记调用作用域和按 SDK 方法常量分派,业务判断仍由所属服务负责。 + 运行诊断直接采样调用表和容量资源,不保存第二套运行状态;API 区分期望启用、发布版本和会话状态,缺少诊断时明确返回未知。 + 负载只关联当前发布实例,排空版本另行列出;字段语义见 [发布状态](api.md#发布状态) - **调用预算由阶段合同统一解释**:SDK 定义场景默认值,插件在 `Ready` 声明所需预算,Runtime 与 SDK 解析并冻结到会话;各入口按实例和阶段读取,宿主不提供部署侧业务时长上限。 注册在进程启动与握手完成后使用自己的预算;CLI 的执行与结果提交共用一次期限,重试委托链共享匹配实例中最长的预算并受请求剩余期限约束,单次调用仍受自身声明限制。 业务预算不延长进程启动与握手、排队、取消确认、关闭和外部请求期限;声明与默认值见 [SDK 调用预算](../backend/crates/gateway-plugin/sdk/README.md#声明调用预算) diff --git a/docs/plugins.md b/docs/plugins.md index 2c45ef649..4a31b8dfc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -69,7 +69,8 @@ flowchart LR 声明自动维护的插件可自行准备分组和 Key,无需提供管理员密码。停用会停止维护,但保留这些资源及其原生启用状态; 删除插件配置也不自动删除资源。需要撤销 Key 访问时,在 Key 管理中停用或删除相应 Key -“已启用”表示配置已经发布,不是外部服务的健康证明。“等待生效”表示候选仍在准备;“需要处理”会显示发布或运行失败原因。 +“已启用”表示配置匹配且宿主观察到会话就绪,不是插件业务或外部服务的健康证明。“等待生效”表示候选仍在准备或等待发布;“需要处理”会显示发布或运行失败原因。 +“运行状态未知”表示暂时没有运行诊断,启用配置保持不变。详情中的“运行负载”展示当前发布会话的在途调用、执行、排队、恢复等待、驻留回调和活动流;计数可能重叠,不包含其他正在排空的旧版本 并发编辑冲突时应重新加载最新配置后确认,不能覆盖另一窗口的修改 ## 使用 diff --git a/frontend/src/api/modules/plugins/index.ts b/frontend/src/api/modules/plugins/index.ts index ec7ffb3b5..74e62e69f 100644 --- a/frontend/src/api/modules/plugins/index.ts +++ b/frontend/src/api/modules/plugins/index.ts @@ -191,6 +191,7 @@ export interface PluginCapabilityBinding { export type PluginInstanceRuntimeStatus = | 'disabled' + | 'unknown' | 'awaiting_publication' | 'preparing' | 'running' @@ -204,12 +205,22 @@ export interface PluginInstanceRuntimeFailure { message: string } +export interface PluginInstanceRuntimeLoad { + activeCalls: number + executingCalls: number + queuedCalls: number + resumingCalls: number + pendingCallbacks: number + activeStreams: number +} + export interface PluginInstanceRuntime { status: PluginInstanceRuntimeStatus actualRevision: number | null actualArtifactSha256: string | null failure: PluginInstanceRuntimeFailure | null drainingRevisions: number[] + load?: PluginInstanceRuntimeLoad | null } export interface PluginInstance { diff --git a/frontend/src/i18n/locales/en/plugins.json b/frontend/src/i18n/locales/en/plugins.json index b75732c0f..c53acbb1b 100644 --- a/frontend/src/i18n/locales/en/plugins.json +++ b/frontend/src/i18n/locales/en/plugins.json @@ -424,7 +424,9 @@ "compatibilityEnding": "The compatibility window has ended, a future stable release will remove the old API adapter", "unsupportedVersionHelp": "Configuration and data are kept, install a supported plugin version", "restartHelp": "The enabled configuration is kept, restart it in plugin details", - "upgradeHelp": "Update the plugin or ask its author to upgrade the API" + "upgradeHelp": "Update the plugin or ask its author to upgrade the API", + "unknown": "Runtime status unknown", + "unknownDescription": "Runtime status is unavailable, the enabled configuration is unchanged" }, "updates": { "packageRequired": "Select a plugin package to confirm its version and compatibility", @@ -530,6 +532,20 @@ "releaseList": "Release list", "noRemoteUpdateSource": "No remote update source" }, - "onePerLine": "One item per line" + "onePerLine": "One item per line", + "runtime": { + "title": "Runtime load", + "helpTitle": "About runtime load", + "idle": "Currently idle", + "inFlightSummary": "{count} active call | {count} active calls", + "revision": "Runtime configuration revision: {revision}", + "description": "Sampled from the current runtime configuration, excluding older configurations that are shutting down, counts may overlap", + "activeCalls": "Active calls", + "executingCalls": "Executing", + "queuedCalls": "Queued", + "resumingCalls": "Awaiting resume", + "pendingCallbacks": "Pending callbacks", + "activeStreams": "Active streams" + } } } diff --git a/frontend/src/i18n/locales/zh-Hans/plugins.json b/frontend/src/i18n/locales/zh-Hans/plugins.json index 58751911e..e6d76da71 100644 --- a/frontend/src/i18n/locales/zh-Hans/plugins.json +++ b/frontend/src/i18n/locales/zh-Hans/plugins.json @@ -424,7 +424,9 @@ "compatibilityEnding": "兼容窗口已满,后续正式版本将移除旧接口转换", "unsupportedVersionHelp": "配置与数据保留,请安装可加载的插件版本", "restartHelp": "启用配置保留,可在插件详情中重新启动", - "upgradeHelp": "请更新插件,或联系作者升级接口" + "upgradeHelp": "请更新插件,或联系作者升级接口", + "unknown": "运行状态未知", + "unknownDescription": "暂时无法读取运行状态,启用配置保持不变" }, "updates": { "packageRequired": "请选择插件包以确认版本与兼容性", @@ -530,6 +532,20 @@ "releaseList": "发行清单", "noRemoteUpdateSource": "无远程更新来源" }, - "onePerLine": "每行一项" + "onePerLine": "每行一项", + "runtime": { + "title": "运行负载", + "helpTitle": "运行负载说明", + "idle": "当前空闲", + "inFlightSummary": "{count} 个在途调用", + "revision": "运行配置版本:{revision}", + "description": "当前运行配置的采样值,不含正在退出的旧配置,计数可能重叠", + "activeCalls": "在途调用", + "executingCalls": "执行中", + "queuedCalls": "排队中", + "resumingCalls": "等待恢复", + "pendingCallbacks": "驻留回调", + "activeStreams": "活动流" + } } } diff --git a/frontend/src/i18n/locales/zh-Hant/plugins.json b/frontend/src/i18n/locales/zh-Hant/plugins.json index 7b9a39b7f..15aad6020 100644 --- a/frontend/src/i18n/locales/zh-Hant/plugins.json +++ b/frontend/src/i18n/locales/zh-Hant/plugins.json @@ -424,7 +424,9 @@ "compatibilityEnding": "兼容窗口已滿,後續正式版本將移除舊接口轉換", "unsupportedVersionHelp": "配置與數據保留,請安裝可加載的插件版本", "restartHelp": "啓用配置保留,可在插件詳情中重新啓動", - "upgradeHelp": "請更新插件,或聯繫作者升級接口" + "upgradeHelp": "請更新插件,或聯繫作者升級接口", + "unknown": "運行狀態未知", + "unknownDescription": "暫時無法讀取運行狀態,啟用配置保持不變" }, "updates": { "packageRequired": "請選擇插件包以確認版本與兼容性", @@ -530,6 +532,20 @@ "releaseList": "發行清單", "noRemoteUpdateSource": "無遠程更新來源" }, - "onePerLine": "每行一項" + "onePerLine": "每行一項", + "runtime": { + "title": "運行負載", + "helpTitle": "運行負載說明", + "idle": "當前空閒", + "inFlightSummary": "{count} 個在途調用", + "revision": "運行配置版本:{revision}", + "description": "當前運行配置的採樣值,不含正在退出的舊配置,計數可能重疊", + "activeCalls": "在途調用", + "executingCalls": "執行中", + "queuedCalls": "排隊中", + "resumingCalls": "等待恢復", + "pendingCallbacks": "駐留回調", + "activeStreams": "活動流" + } } } diff --git a/frontend/src/views/plugins/components/PluginDetailModal.vue b/frontend/src/views/plugins/components/PluginDetailModal.vue index 53db05736..75c85a588 100644 --- a/frontend/src/views/plugins/components/PluginDetailModal.vue +++ b/frontend/src/views/plugins/components/PluginDetailModal.vue @@ -15,6 +15,7 @@ import { artifactForInstance } from '../utils/model' import PluginConfigurationSummary from './PluginConfigurationSummary.vue' import PluginHelpPopover from './PluginHelpPopover.vue' import PluginLegacyConfigurations from './PluginLegacyConfigurations.vue' +import PluginRuntimeLoad from './PluginRuntimeLoad.vue' import PluginStatusNotice from './PluginStatusNotice.vue' import PluginVersionsPanel from './PluginVersionsPanel.vue' @@ -65,6 +66,8 @@ function configurationNotes(instance: PluginInstance) { notes.push(t('plugins.details.pendingDescription')) if (configurationStatus(instance) === 'unconfigured') notes.push(t('plugins.details.requiredConfiguration')) + if (configurationStatus(instance) === 'unknown') + notes.push(t('plugins.status.unknownDescription')) if (instance.runtime.drainingRevisions.length) notes.push(t('plugins.details.versionInUse')) return notes @@ -120,6 +123,7 @@ watch(() => props.initialSection, value => section.value = value) :metadata="artifactForInstance(instance, plugin.artifacts)?.metadata" :show-command="configurationStatus(instance) === 'enabled' && Boolean(capabilities(instance).command_line)" /> +
diff --git a/frontend/src/views/plugins/components/PluginRuntimeLoad.vue b/frontend/src/views/plugins/components/PluginRuntimeLoad.vue new file mode 100644 index 000000000..dc35cbb72 --- /dev/null +++ b/frontend/src/views/plugins/components/PluginRuntimeLoad.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/views/plugins/composables/usePluginFrameSession.ts b/frontend/src/views/plugins/composables/usePluginFrameSession.ts index fde0e4c71..2d6c6a994 100644 --- a/frontend/src/views/plugins/composables/usePluginFrameSession.ts +++ b/frontend/src/views/plugins/composables/usePluginFrameSession.ts @@ -1,10 +1,10 @@ import type { ShallowRef } from 'vue' +import type { PluginBridgeMessage, PluginModelMessageType } from '../management/bridge-contract' import type { PluginManagementPage, PluginManagementRoute, PluginManagementView } from '@/api' import { useEventListener } from '@vueuse/core' import { computed, nextTick, onScopeDispose, shallowRef, watch } from 'vue' import { callPluginManagementRoute, - callPluginModelResponses, createPluginManagementCallbackTicket, pluginManagementCallbackPath, } from '@/api' @@ -12,31 +12,18 @@ import { API_BASE_URL } from '@/api/constants' import { ApiError } from '@/api/request' import { t } from '@/i18n' import { errorMessage, generateRequestId } from '@/utils/operation' +import { isPluginBridgeRequest, isRecord } from '../management/bridge-contract' import { assemblePluginManagementPage, hasControlCharacter, MAXIMUM_MANAGEMENT_BODY_BYTES, MAXIMUM_MANAGEMENT_IN_FLIGHT, MAXIMUM_MANAGEMENT_QUERY_BYTES, - MAXIMUM_MODEL_BODY_BYTES, - MAXIMUM_MODEL_CHUNK_BYTES, - MAXIMUM_MODEL_IN_FLIGHT, - MODEL_REQUEST_DEADLINE_MS, - MODEL_RESPONSE_IDLE_MS, PLUGIN_MANAGEMENT_BRIDGE, PLUGIN_MANAGEMENT_BRIDGE_VERSION, readPluginFrameTheme, } from '../management/frame' - -interface BridgeMessage { - target: string - version: number - channel: string - session: string - type: string - id: number - payload?: unknown -} +import { createPluginModelStreams } from '../management/model-streams' interface FrameSession { channel: string @@ -45,26 +32,11 @@ interface FrameSession { view: PluginManagementView page: PluginManagementPage revoke: () => void - inFlight: number requestIds: Set - modelRequests: Map + models: ReturnType loaded: boolean } -interface ModelRequest { - id: number - source: Window - controller: AbortController - reader?: ReadableStreamDefaultReader - buffered?: Uint8Array - bufferedOffset: number - reading: boolean - nextPullId: number - activePullId?: number - totalTimer?: ReturnType - idleTimer?: ReturnType -} - export function usePluginFrameSession(options: { view: () => PluginManagementView page: () => PluginManagementPage @@ -91,8 +63,7 @@ export function usePluginFrameSession(options: { if (!current) return current.controller.abort() - for (const request of current.modelRequests.values()) - terminateModelRequest(current, request) + current.models.dispose() current.revoke() } @@ -124,18 +95,23 @@ export function usePluginFrameSession(options: { assembled.revoke() return } - session.value = { + const requestIds = new Set() + const current: FrameSession = { channel, session: sessionId, controller, view, page, revoke: assembled.revoke, - inFlight: 0, - requestIds: new Set(), - modelRequests: new Map(), + requestIds, + models: createPluginModelStreams({ + target: view.target, + requestIds, + post: (...args) => postModelMessage(current, ...args), + }), loaded: false, } + session.value = current srcdoc.value = assembled.srcdoc } catch (error) { @@ -172,23 +148,6 @@ export function usePluginFrameSession(options: { disposeSession() } - function isBridgeMessage(value: unknown): value is BridgeMessage { - if (!value || typeof value !== 'object') - return false - const message = value as Record - return message.target === PLUGIN_MANAGEMENT_BRIDGE - && message.version === PLUGIN_MANAGEMENT_BRIDGE_VERSION - && typeof message.channel === 'string' - && typeof message.session === 'string' - && typeof message.type === 'string' - && Number.isSafeInteger(message.id) - && Number(message.id) > 0 - } - - function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)) - } - function postResult( current: FrameSession, source: Window, @@ -253,7 +212,7 @@ export function usePluginFrameSession(options: { async function handleRouteRequest( current: FrameSession, source: Window, - message: BridgeMessage, + message: PluginBridgeMessage, ) { if (!isRecord(message.payload)) throw new Error(t('plugins.frame.invalidRequestParameters')) @@ -279,7 +238,7 @@ export function usePluginFrameSession(options: { async function handleCallbackTicket( current: FrameSession, source: Window, - message: BridgeMessage, + message: PluginBridgeMessage, ) { if (!isRecord(message.payload)) throw new Error(t('plugins.frame.invalidCallbackTicket')) @@ -306,7 +265,7 @@ export function usePluginFrameSession(options: { function postModelMessage( current: FrameSession, source: Window, - type: 'models-response' | 'models-chunk' | 'models-complete' | 'models-error', + type: PluginModelMessageType, id: number, payload?: unknown, error?: string, @@ -326,236 +285,6 @@ export function usePluginFrameSession(options: { }, '*', transfer) } - function validateModelRequestPayload(payload: unknown) { - if (!isRecord(payload)) - throw new Error(t('plugins.frame.invalidModelRequest')) - const clientKeyId = payload.clientKeyId - const body = payload.body - if ( - typeof clientKeyId !== 'string' - || !clientKeyId - || clientKeyId !== clientKeyId.trim() - || clientKeyId.length > 128 - || hasControlCharacter(clientKeyId) - ) { - throw new Error(t('plugins.frame.clientkeyidIsInvalid')) - } - if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > MAXIMUM_MODEL_BODY_BYTES) - throw new Error(t('plugins.frame.invalidModelBody')) - let parsed: unknown - try { - parsed = JSON.parse(body) - } - catch { - throw new Error(t('plugins.frame.invalidModelJson')) - } - if (!isRecord(parsed)) - throw new Error(t('plugins.frame.modelObjectRequired')) - return { clientKeyId, body } - } - - function modelResponseHeaders(response: Response) { - // 完整转交浏览器可读取的响应头;浏览器自身的 Headers 合同仍然适用。 - return Array.from(response.headers.entries()) - } - - function supportedModelResponse(response: Response) { - if (!response.body) - return true - const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - return contentType === 'application/json' - || contentType === 'text/event-stream' - || Boolean(contentType?.endsWith('+json')) - } - - function detachModelRequest(current: FrameSession, request: ModelRequest) { - if (current.modelRequests.get(request.id) !== request) - return false - current.modelRequests.delete(request.id) - clearTimeout(request.totalTimer) - clearTimeout(request.idleTimer) - request.totalTimer = undefined - request.idleTimer = undefined - return true - } - - function cancelModelReader(request: ModelRequest) { - const reader = request.reader - if (!reader) - return - void reader.cancel() - .finally(() => { - try { - reader.releaseLock() - } - catch { - // 取消中的 read 会自行释放;会话清理不能因此产生未处理拒绝。 - } - }) - .catch(() => undefined) - } - - function terminateModelRequest(current: FrameSession, request: ModelRequest, error?: string) { - if (!detachModelRequest(current, request)) - return - request.controller.abort() - cancelModelReader(request) - if (error) - postModelMessage(current, request.source, 'models-error', request.id, undefined, error) - } - - function completeModelRequest(current: FrameSession, request: ModelRequest, pullId: number) { - if (!detachModelRequest(current, request)) - return - try { - request.reader?.releaseLock() - } - catch { - // EOF 已确定,锁清理失败不应阻断结束标记。 - } - postModelMessage(current, request.source, 'models-complete', request.id, { pullId }) - } - - function armModelIdleTimeout(current: FrameSession, request: ModelRequest) { - clearTimeout(request.idleTimer) - request.idleTimer = setTimeout(() => { - terminateModelRequest(current, request, t('plugins.frame.pageDidNotContinueToReadModel')) - }, MODEL_RESPONSE_IDLE_MS) - } - - async function handleModelRequest(current: FrameSession, source: Window, message: BridgeMessage) { - if (current.modelRequests.size >= MAXIMUM_MODEL_IN_FLIGHT) { - postModelMessage(current, source, 'models-error', message.id, undefined, t('plugins.frame.modelConcurrencyLimit')) - return - } - if (current.requestIds.has(message.id) || current.modelRequests.has(message.id)) { - postModelMessage(current, source, 'models-error', message.id, undefined, t('plugins.frame.duplicateModelRequest')) - return - } - - let payload: ReturnType - try { - payload = validateModelRequestPayload(message.payload) - } - catch (error) { - postModelMessage(current, source, 'models-error', message.id, undefined, errorMessage(error)) - return - } - - const request: ModelRequest = { - id: message.id, - source, - controller: new AbortController(), - bufferedOffset: 0, - reading: false, - nextPullId: 1, - } - current.modelRequests.set(message.id, request) - request.totalTimer = setTimeout(() => { - terminateModelRequest(current, request, t('plugins.frame.modelTimeout')) - }, MODEL_REQUEST_DEADLINE_MS) - - try { - const response = await callPluginModelResponses( - current.view.target, - payload.clientKeyId, - payload.body, - request.controller.signal, - ) - if (current.modelRequests.get(request.id) !== request) - return - if (!supportedModelResponse(response)) - throw new Error(t('plugins.frame.unsupportedModelContentType')) - - const hasBody = response.body !== null - if (response.body) - request.reader = response.body.getReader() - const statusText = response.statusText.length <= 256 && !hasControlCharacter(response.statusText) - ? response.statusText - : '' - postModelMessage(current, source, 'models-response', message.id, { - status: response.status, - statusText, - headers: modelResponseHeaders(response), - hasBody, - }) - if (!hasBody) { - detachModelRequest(current, request) - return - } - armModelIdleTimeout(current, request) - } - catch (error) { - if (current.modelRequests.get(request.id) === request) - terminateModelRequest(current, request, errorMessage(error, t('plugins.frame.modelRequestFailed'))) - } - } - - async function handleModelPull(current: FrameSession, source: Window, message: BridgeMessage) { - const request = current.modelRequests.get(message.id) - if (!request || request.source !== source) - return - const pullId = isRecord(message.payload) - ? message.payload.pullId - : undefined - if (!Number.isSafeInteger(pullId) || Number(pullId) < 1) { - terminateModelRequest(current, request, t('plugins.frame.invalidPullId')) - return - } - if (request.reading) { - if (request.activePullId !== pullId) - terminateModelRequest(current, request, t('plugins.frame.concurrentPull')) - return - } - if (pullId !== request.nextPullId) { - terminateModelRequest(current, request, t('plugins.frame.stalePullId')) - return - } - clearTimeout(request.idleTimer) - request.idleTimer = undefined - request.reading = true - request.activePullId = Number(pullId) - try { - while (current.modelRequests.get(message.id) === request) { - if (!request.buffered || request.bufferedOffset >= request.buffered.byteLength) { - request.buffered = undefined - request.bufferedOffset = 0 - const result = await request.reader?.read() - if (current.modelRequests.get(message.id) !== request) - return - if (!result || result.done) { - completeModelRequest(current, request, Number(pullId)) - return - } - if (result.value.byteLength === 0) - continue - request.buffered = result.value - } - - const end = Math.min(request.bufferedOffset + MAXIMUM_MODEL_CHUNK_BYTES, request.buffered.byteLength) - const chunk = request.buffered.slice(request.bufferedOffset, end) - request.bufferedOffset = end - if (request.bufferedOffset >= request.buffered.byteLength) { - request.buffered = undefined - request.bufferedOffset = 0 - } - const body = chunk.buffer - request.nextPullId += 1 - postModelMessage(current, source, 'models-chunk', message.id, { pullId, body }, undefined, [body]) - armModelIdleTimeout(current, request) - return - } - } - catch (error) { - if (current.modelRequests.get(message.id) === request) - terminateModelRequest(current, request, errorMessage(error, t('plugins.frame.modelResponseReadFailed'))) - } - finally { - request.reading = false - request.activePullId = undefined - } - } - async function handleBridgeMessage(event: MessageEvent) { const current = session.value const source = event.source @@ -563,7 +292,7 @@ export function usePluginFrameSession(options: { !current || source !== options.iframe.value?.contentWindow || event.origin !== 'null' - || !isBridgeMessage(event.data) + || !isPluginBridgeRequest(event.data) || event.data.channel !== current.channel || event.data.session !== current.session ) { @@ -579,30 +308,27 @@ export function usePluginFrameSession(options: { return } if (event.data.type === 'models-request') { - void handleModelRequest(current, frame, event.data) + void current.models.request(frame, event.data) return } if (event.data.type === 'models-pull') { - void handleModelPull(current, frame, event.data) + void current.models.pull(frame, event.data) return } if (event.data.type === 'models-cancel') { - const request = current.modelRequests.get(event.data.id) - if (request?.source === frame) - terminateModelRequest(current, request) + current.models.cancel(frame, event.data.id) return } if (!['request', 'callback-ticket'].includes(event.data.type)) return - if (current.requestIds.has(event.data.id) || current.modelRequests.has(event.data.id)) { + if (current.requestIds.has(event.data.id) || current.models.has(event.data.id)) { postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.duplicateManagementRequest')) return } - if (current.inFlight >= MAXIMUM_MANAGEMENT_IN_FLIGHT) { + if (current.requestIds.size >= MAXIMUM_MANAGEMENT_IN_FLIGHT) { postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.managementConcurrencyLimit')) return } - current.inFlight += 1 current.requestIds.add(event.data.id) try { if (event.data.type === 'request') @@ -622,7 +348,6 @@ export function usePluginFrameSession(options: { } finally { current.requestIds.delete(event.data.id) - current.inFlight = Math.max(0, current.inFlight - 1) } } diff --git a/frontend/src/views/plugins/composables/usePluginManagement.ts b/frontend/src/views/plugins/composables/usePluginManagement.ts index 512cb2255..b2739533d 100644 --- a/frontend/src/views/plugins/composables/usePluginManagement.ts +++ b/frontend/src/views/plugins/composables/usePluginManagement.ts @@ -170,14 +170,20 @@ export function usePluginManagement() { clearTimeout(pollTimer) if (disposed) return - if (instances.value.some(instance => instance.enabled && ['awaiting_publication', 'preparing', 'draining'].includes(instance.runtime.status))) { + const currentVisible = showDetail.value && currentPlugin.value?.configurations.some(instance => instance.enabled) + if (currentVisible || instances.value.some(instance => instance.enabled && [ + 'awaiting_publication', + 'preparing', + 'draining', + 'unknown', + ].includes(instance.runtime.status))) { pollTimer = setTimeout(async () => { await refresh(true, true) schedulePoll() }, 3000) } } - watch(instances, schedulePoll) + watch([instances, showDetail, selectedPluginId], schedulePoll) watch([currentPlugin, showDetail], ([plugin, visible]) => { if (!visible) return diff --git a/frontend/src/views/plugins/constants.ts b/frontend/src/views/plugins/constants.ts index 96188beb6..881705bdb 100644 --- a/frontend/src/views/plugins/constants.ts +++ b/frontend/src/views/plugins/constants.ts @@ -88,4 +88,5 @@ export const PLUGIN_STATUS_LABELS = computed disabled: t('plugins.fields.deactivated'), pending: t('plugins.fields.waitingForItToTakeEffect'), failed: t('common.status.error'), + unknown: t('plugins.status.unknown'), })) diff --git a/frontend/src/views/plugins/management/bridge-contract.ts b/frontend/src/views/plugins/management/bridge-contract.ts index 2dac5814c..6467cad69 100644 --- a/frontend/src/views/plugins/management/bridge-contract.ts +++ b/frontend/src/views/plugins/management/bridge-contract.ts @@ -9,6 +9,64 @@ export const MAXIMUM_MODEL_IN_FLIGHT = 4 export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000 export const MODEL_RESPONSE_IDLE_MS = 30 * 1000 +export type PluginPageMessageType = 'request' | 'callback-ticket' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel' +export type PluginModelMessageType = 'models-response' | 'models-chunk' | 'models-complete' | 'models-error' + +export interface PluginBridgeMessage { + target: string + version: number + channel: string + session: string + type: string + id: number + payload?: unknown +} + +export interface PluginModelMessage { + type: PluginModelMessageType + id: number + payload?: Record + error?: unknown +} + +export interface PluginManagementRequest { + method: string + path: string + query?: string + contentType?: string + body?: string | ArrayBuffer | ArrayBufferView +} + +export interface PluginManagementReply { + status: number + contentType: string + body: ArrayBuffer +} + +export interface PluginCallbackTicket { + state: string + expiresAtMs: number + callbackUrl: string +} + +export interface PluginModelRequest { + clientKeyId: string + body: Record + signal?: AbortSignal +} + +// 注入 API 的公开类型与实际安装对象共用合同,不暴露内部消息或资源索引 +export interface PluginHostBridge { + readonly version: typeof PLUGIN_MANAGEMENT_BRIDGE_VERSION + readonly plugin: Readonly<{ name: string }> + readonly page: Readonly<{ id: string, title: string }> + readonly theme: 'light' | 'dark' + request: (input: PluginManagementRequest) => Promise + callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise + resourceUrl: (path: string) => string + readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise }> +} + export interface PluginFrameTheme { name: 'light' | 'dark' tokens: Record @@ -16,7 +74,7 @@ export interface PluginFrameTheme { export interface PluginBridgeInitial { target: string - version: number + version: typeof PLUGIN_MANAGEMENT_BRIDGE_VERSION channel: string session: string parentOrigin: string @@ -29,6 +87,21 @@ export interface PluginBridgeInitial { apiTimeoutMs: number } +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export function isPluginBridgeRequest(value: unknown): value is PluginBridgeMessage { + return isRecord(value) + && value.target === PLUGIN_MANAGEMENT_BRIDGE + && value.version === PLUGIN_MANAGEMENT_BRIDGE_VERSION + && typeof value.channel === 'string' + && typeof value.session === 'string' + && typeof value.type === 'string' + && Number.isSafeInteger(value.id) + && Number(value.id) > 0 +} + export function hasControlCharacter(value: string) { return [...value].some((character) => { const code = character.codePointAt(0) ?? 0 diff --git a/frontend/src/views/plugins/management/bridge-models.ts b/frontend/src/views/plugins/management/bridge-models.ts new file mode 100644 index 000000000..2fc5ee2db --- /dev/null +++ b/frontend/src/views/plugins/management/bridge-models.ts @@ -0,0 +1,246 @@ +import type { PluginModelMessage, PluginModelRequest, PluginPageMessageType } from './bridge-contract' +import { hasControlCharacter, MAXIMUM_MODEL_BODY_BYTES, MAXIMUM_MODEL_CHUNK_BYTES, MAXIMUM_MODEL_IN_FLIGHT, MODEL_REQUEST_DEADLINE_MS } from './bridge-contract' + +interface ModelOperation { + id: number + resolve: (value: Response) => void + reject: (error: Error) => void + signal?: AbortSignal + abort?: () => void + timer?: number + controller?: ReadableStreamDefaultController> + responseStarted: boolean + pullPromise?: Promise + pullResolve?: () => void + pullReject?: (error: Error) => void + pullSequence: number + pullId?: number +} + +// 注入端拥有 Response 与待完成读取,传输身份和页面寿命由页面桥提供 +export function createPluginModelBridge(options: { + nextRequestId: () => number + isDisposed: () => boolean + post: (type: PluginPageMessageType, id: number, payload?: unknown) => void +}) { + const modelOperations = new Map() + function abortError(reason: unknown) { + return reason instanceof Error + ? reason + : new DOMException('Model request canceled', 'AbortError') + } + + function clearModelOperation(operation: ModelOperation) { + if (modelOperations.get(operation.id) !== operation) + return + modelOperations.delete(operation.id) + clearTimeout(operation.timer) + if (operation.signal && operation.abort) + operation.signal.removeEventListener('abort', operation.abort) + } + + function settleModelPull(operation: ModelOperation, error?: Error) { + const resolve = operation.pullResolve + const reject = operation.pullReject + operation.pullResolve = undefined + operation.pullReject = undefined + operation.pullPromise = undefined + operation.pullId = undefined + if (error) + reject?.(error) + else resolve?.() + } + + function failModelOperation(operation: ModelOperation, error: Error, notifyParent: boolean) { + if (modelOperations.get(operation.id) !== operation) + return + clearModelOperation(operation) + settleModelPull(operation, error) + if (operation.controller) { + try { + operation.controller.error(error) + } + catch {} + } + else { + operation.reject(error) + } + if (notifyParent) + options.post('models-cancel', operation.id) + } + + function finishModelOperation(operation: ModelOperation) { + if (modelOperations.get(operation.id) !== operation) + return + clearModelOperation(operation) + settleModelPull(operation) + } + + function validateResponseHeaders(value: unknown) { + if (!Array.isArray(value)) + throw new TypeError('Model response header is invalid') + for (const entry of value) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') + throw new Error('Model response header is invalid') + } + // 与宿主共享浏览器的 Headers 校验,不再按插件身份过滤字段。 + return new Headers(value as [string, string][]) + } + + function handleModelMessage(message: PluginModelMessage) { + const operation = modelOperations.get(message.id) + if (!operation) + return + if (message.type === 'models-error') { + const text = typeof message.error === 'string' && message.error + ? message.error + : 'Model request failed' + failModelOperation(operation, new Error(text), false) + return + } + if (message.type === 'models-response') { + if (operation.controller || operation.responseStarted || !message.payload || typeof message.payload !== 'object') { + failModelOperation(operation, new Error('Invalid model response protocol'), true) + return + } + try { + const status = message.payload.status + const statusText = message.payload.statusText ?? '' + const hasBody = message.payload.hasBody + if (typeof status !== 'number' || !Number.isSafeInteger(status) || status < 200 || status > 599 || typeof statusText !== 'string' || statusText.length > 256 || hasControlCharacter(statusText) || typeof hasBody !== 'boolean') + throw new Error('Model response status is invalid') + const headers = validateResponseHeaders(message.payload.headers) + operation.responseStarted = true + if (!hasBody) { + finishModelOperation(operation) + operation.resolve(new Response(null, { status, statusText, headers })) + return + } + const stream = new ReadableStream({ + start(controller) { + operation.controller = controller + }, + pull() { + if (modelOperations.get(operation.id) !== operation) + return undefined + if (operation.pullPromise) + return operation.pullPromise + operation.pullSequence += 1 + operation.pullId = operation.pullSequence + operation.pullPromise = new Promise((resolve, reject) => { + operation.pullResolve = resolve + operation.pullReject = reject + options.post('models-pull', operation.id, { pullId: operation.pullId }) + }) + return operation.pullPromise + }, + cancel() { + if (modelOperations.get(operation.id) === operation) { + clearModelOperation(operation) + settleModelPull(operation) + options.post('models-cancel', operation.id) + } + }, + }, { highWaterMark: 0 }) + operation.resolve(new Response(stream, { status, statusText, headers })) + } + catch (error) { + failModelOperation(operation, error instanceof Error + ? error + : new Error('Invalid model response protocol'), true) + } + return + } + if (!operation.controller || !operation.responseStarted) { + failModelOperation(operation, new Error('Invalid model response protocol'), true) + return + } + if (message.type === 'models-chunk') { + const pullId = message.payload?.pullId + const body = message.payload?.body + if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId || !(body instanceof ArrayBuffer) || body.byteLength === 0 || body.byteLength > MAXIMUM_MODEL_CHUNK_BYTES) { + failModelOperation(operation, new Error('Invalid model response chunk'), true) + return + } + try { + operation.controller.enqueue(new Uint8Array(body)) + settleModelPull(operation) + } + catch (error) { + failModelOperation(operation, error instanceof Error + ? error + : new Error('Model response read failed'), true) + } + return + } + if (message.type === 'models-complete') { + const pullId = message.payload?.pullId + if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId) { + failModelOperation(operation, new Error('Model response end tag is invalid'), true) + return + } + try { + operation.controller.close() + } + catch {} + finishModelOperation(operation) + } + } + + function modelResponses(input: PluginModelRequest): Promise { + if (options.isDisposed()) + return Promise.reject(new Error('Plugin page is closed')) + if (!input || typeof input !== 'object' || !input.body || typeof input.body !== 'object' || Array.isArray(input.body)) + return Promise.reject(new TypeError('Responses request body must be a JSON object')) + if (typeof input.clientKeyId !== 'string' || !input.clientKeyId || input.clientKeyId !== input.clientKeyId.trim() || input.clientKeyId.length > 128 || hasControlCharacter(input.clientKeyId)) + return Promise.reject(new TypeError('clientKeyId is invalid')) + if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) + return Promise.reject(new TypeError('signal must be AbortSignal')) + if (input.signal?.aborted) + return Promise.reject(abortError(input.signal.reason)) + if (modelOperations.size >= MAXIMUM_MODEL_IN_FLIGHT) + return Promise.reject(new Error('Model request concurrency limit reached')) + let body + try { + body = JSON.stringify(input.body) + } + catch { + return Promise.reject(new TypeError('Responses request body must be JSON serializable')) + } + if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > MAXIMUM_MODEL_BODY_BYTES) + return Promise.reject(new TypeError('Responses request body exceeds 8 MiB')) + const id = options.nextRequestId() + return new Promise((resolve, reject) => { + const operation: ModelOperation = { + id, + resolve, + reject, + signal: input.signal, + abort: undefined, + timer: undefined, + controller: undefined, + responseStarted: false, + pullPromise: undefined, + pullResolve: undefined, + pullReject: undefined, + pullSequence: 0, + pullId: undefined, + } + operation.abort = () => failModelOperation(operation, abortError(input.signal?.reason), true) + operation.timer = window.setTimeout(() => failModelOperation(operation, new Error('Model request exceeded 10 minutes'), true), MODEL_REQUEST_DEADLINE_MS) + modelOperations.set(id, operation) + input.signal?.addEventListener('abort', operation.abort, { once: true }) + options.post('models-request', id, { clientKeyId: input.clientKeyId, body }) + }) + } + + return { + responses: modelResponses, + handle: handleModelMessage, + dispose() { + const error = new Error('Plugin page is closed') + for (const operation of modelOperations.values()) + failModelOperation(operation, error, true) + }, + } +} diff --git a/frontend/src/views/plugins/management/bridge-runtime.ts b/frontend/src/views/plugins/management/bridge-runtime.ts index 612059246..2636bd2a6 100644 --- a/frontend/src/views/plugins/management/bridge-runtime.ts +++ b/frontend/src/views/plugins/management/bridge-runtime.ts @@ -1,5 +1,6 @@ -import type { PluginBridgeInitial } from './bridge-contract' -import { hasControlCharacter, MAXIMUM_MANAGEMENT_BODY_BYTES, MAXIMUM_MANAGEMENT_IN_FLIGHT, MAXIMUM_MANAGEMENT_QUERY_BYTES, MAXIMUM_MODEL_BODY_BYTES, MAXIMUM_MODEL_CHUNK_BYTES, MAXIMUM_MODEL_IN_FLIGHT, MODEL_REQUEST_DEADLINE_MS } from './bridge-contract' +import type { PluginBridgeInitial, PluginCallbackTicket, PluginHostBridge, PluginManagementReply, PluginManagementRequest, PluginPageMessageType } from './bridge-contract' +import { isRecord, MAXIMUM_MANAGEMENT_BODY_BYTES, MAXIMUM_MANAGEMENT_IN_FLIGHT, MAXIMUM_MANAGEMENT_QUERY_BYTES } from './bridge-contract' +import { createPluginModelBridge } from './bridge-models' interface PendingOperation { resolve: (value: unknown) => void @@ -7,50 +8,8 @@ interface PendingOperation { timer: number } -interface ModelOperation { - id: number - resolve: (value: Response) => void - reject: (error: Error) => void - signal?: AbortSignal - abort?: () => void - timer?: number - controller?: ReadableStreamDefaultController> - responseStarted: boolean - pullPromise?: Promise - pullResolve?: () => void - pullReject?: (error: Error) => void - pullSequence: number - pullId?: number -} - -interface ModelMessage { - id: number - type: string - payload?: Record - error?: unknown -} - -interface ManagementRequest { - method: string - path: string - query?: string - contentType?: string - body?: string | ArrayBuffer | ArrayBufferView -} - -interface ModelRequest { - clientKeyId: string - body: Record - signal?: AbortSignal -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - export default function installPluginBridge(initial: PluginBridgeInitial) { const pending = new Map() - const modelOperations = new Map() let sequence = 0 let resizeObserver: ResizeObserver | undefined let resizeFrame = 0 @@ -60,10 +19,6 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { const maximumBodyBytes = MAXIMUM_MANAGEMENT_BODY_BYTES const maximumQueryBytes = MAXIMUM_MANAGEMENT_QUERY_BYTES const maximumInFlight = MAXIMUM_MANAGEMENT_IN_FLIGHT - const maximumModelBodyBytes = MAXIMUM_MODEL_BODY_BYTES - const maximumModelChunkBytes = MAXIMUM_MODEL_CHUNK_BYTES - const maximumModelInFlight = MAXIMUM_MODEL_IN_FLIGHT - const modelRequestDeadlineMs = MODEL_REQUEST_DEADLINE_MS function applyLayout(height: unknown) { if (typeof height === 'number' && Number.isSafeInteger(height) && height >= 0) { @@ -106,7 +61,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { return sequence } - function post(type: string, id: number, payload?: unknown, transfer: Transferable[] = []) { + function post(type: PluginPageMessageType, id: number, payload?: unknown, transfer: Transferable[] = []) { parent.postMessage({ target: initial.target, version: initial.version, @@ -118,7 +73,13 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { }, initial.parentOrigin, transfer) } - function invoke(kind: string, payload: unknown, transfer: Transferable[] = []): Promise { + const modelBridge = createPluginModelBridge({ + nextRequestId, + post, + isDisposed: () => disposed, + }) + + function invoke(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise { if (disposed) return Promise.reject(new Error('Plugin page is closed')) if (pending.size >= maximumInFlight) @@ -131,7 +92,11 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { }, kind === 'request' ? initial.managementTimeoutMs : initial.apiTimeoutMs) - pending.set(id, { resolve, reject, timer }) + pending.set(id, { + resolve: value => resolve(value as T), + reject, + timer, + }) post(kind, id, payload, transfer) }) } @@ -166,7 +131,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { window.addEventListener('load', observeContentHeight, { once: true }) - async function request(input: ManagementRequest) { + async function request(input: PluginManagementRequest) { if (!input || typeof input !== 'object') throw new TypeError('Invalid plugin management request') const method = String(input.method || '').toUpperCase() @@ -182,7 +147,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { const body = await bodyBytes(input.body) if (body.byteLength > maximumBodyBytes) throw new TypeError('Plugin management request body exceeds 1 MiB') - return invoke('request', { + return invoke('request', { method, path, query, @@ -194,222 +159,12 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { function callbackTicket(input: { path: string, ttlSeconds: number }) { if (!input || typeof input !== 'object') return Promise.reject(new TypeError('Invalid plugin callback ticket request')) - return invoke('callback-ticket', { + return invoke('callback-ticket', { path: String(input.path || ''), ttlSeconds: Number(input.ttlSeconds), }) } - function abortError(reason: unknown) { - return reason instanceof Error - ? reason - : new DOMException('Model request canceled', 'AbortError') - } - - function clearModelOperation(operation: ModelOperation) { - if (modelOperations.get(operation.id) !== operation) - return - modelOperations.delete(operation.id) - clearTimeout(operation.timer) - if (operation.signal && operation.abort) - operation.signal.removeEventListener('abort', operation.abort) - } - - function settleModelPull(operation: ModelOperation, error?: Error) { - const resolve = operation.pullResolve - const reject = operation.pullReject - operation.pullResolve = undefined - operation.pullReject = undefined - operation.pullPromise = undefined - operation.pullId = undefined - if (error) - reject?.(error) - else resolve?.() - } - - function failModelOperation(operation: ModelOperation, error: Error, notifyParent: boolean) { - if (modelOperations.get(operation.id) !== operation) - return - clearModelOperation(operation) - settleModelPull(operation, error) - if (operation.controller) { - try { - operation.controller.error(error) - } - catch {} - } - else { - operation.reject(error) - } - if (notifyParent) - post('models-cancel', operation.id) - } - - function finishModelOperation(operation: ModelOperation) { - if (modelOperations.get(operation.id) !== operation) - return - clearModelOperation(operation) - settleModelPull(operation) - } - - function validateResponseHeaders(value: unknown) { - if (!Array.isArray(value)) - throw new TypeError('Model response header is invalid') - for (const entry of value) { - if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') - throw new Error('Model response header is invalid') - } - // 与宿主共享浏览器的 Headers 校验,不再按插件身份过滤字段。 - return new Headers(value as [string, string][]) - } - - function handleModelMessage(message: ModelMessage) { - const operation = modelOperations.get(message.id) - if (!operation) - return - if (message.type === 'models-error') { - const text = typeof message.error === 'string' && message.error - ? message.error - : 'Model request failed' - failModelOperation(operation, new Error(text), false) - return - } - if (message.type === 'models-response') { - if (operation.controller || operation.responseStarted || !message.payload || typeof message.payload !== 'object') { - failModelOperation(operation, new Error('Invalid model response protocol'), true) - return - } - try { - const status = message.payload.status - const statusText = message.payload.statusText ?? '' - const hasBody = message.payload.hasBody - if (typeof status !== 'number' || !Number.isSafeInteger(status) || status < 200 || status > 599 || typeof statusText !== 'string' || statusText.length > 256 || hasControlCharacter(statusText) || typeof hasBody !== 'boolean') - throw new Error('Model response status is invalid') - const headers = validateResponseHeaders(message.payload.headers) - operation.responseStarted = true - if (!hasBody) { - finishModelOperation(operation) - operation.resolve(new Response(null, { status, statusText, headers })) - return - } - const stream = new ReadableStream({ - start(controller) { - operation.controller = controller - }, - pull() { - if (modelOperations.get(operation.id) !== operation) - return undefined - if (operation.pullPromise) - return operation.pullPromise - operation.pullSequence += 1 - operation.pullId = operation.pullSequence - operation.pullPromise = new Promise((resolve, reject) => { - operation.pullResolve = resolve - operation.pullReject = reject - post('models-pull', operation.id, { pullId: operation.pullId }) - }) - return operation.pullPromise - }, - cancel() { - if (modelOperations.get(operation.id) === operation) { - clearModelOperation(operation) - settleModelPull(operation) - post('models-cancel', operation.id) - } - }, - }, { highWaterMark: 0 }) - operation.resolve(new Response(stream, { status, statusText, headers })) - } - catch (error) { - failModelOperation(operation, error instanceof Error - ? error - : new Error('Invalid model response protocol'), true) - } - return - } - if (!operation.controller || !operation.responseStarted) { - failModelOperation(operation, new Error('Invalid model response protocol'), true) - return - } - if (message.type === 'models-chunk') { - const pullId = message.payload?.pullId - const body = message.payload?.body - if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId || !(body instanceof ArrayBuffer) || body.byteLength === 0 || body.byteLength > maximumModelChunkBytes) { - failModelOperation(operation, new Error('Invalid model response chunk'), true) - return - } - try { - operation.controller.enqueue(new Uint8Array(body)) - settleModelPull(operation) - } - catch (error) { - failModelOperation(operation, error instanceof Error - ? error - : new Error('Model response read failed'), true) - } - return - } - if (message.type === 'models-complete') { - const pullId = message.payload?.pullId - if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId) { - failModelOperation(operation, new Error('Model response end tag is invalid'), true) - return - } - try { - operation.controller.close() - } - catch {} - finishModelOperation(operation) - } - } - - function modelResponses(input: ModelRequest): Promise { - if (disposed) - return Promise.reject(new Error('Plugin page is closed')) - if (!input || typeof input !== 'object' || !input.body || typeof input.body !== 'object' || Array.isArray(input.body)) - return Promise.reject(new TypeError('Responses request body must be a JSON object')) - if (typeof input.clientKeyId !== 'string' || !input.clientKeyId || input.clientKeyId !== input.clientKeyId.trim() || input.clientKeyId.length > 128 || hasControlCharacter(input.clientKeyId)) - return Promise.reject(new TypeError('clientKeyId is invalid')) - if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) - return Promise.reject(new TypeError('signal must be AbortSignal')) - if (input.signal?.aborted) - return Promise.reject(abortError(input.signal.reason)) - if (modelOperations.size >= maximumModelInFlight) - return Promise.reject(new Error('Model request concurrency limit reached')) - let body - try { - body = JSON.stringify(input.body) - } - catch { - return Promise.reject(new TypeError('Responses request body must be JSON serializable')) - } - if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > maximumModelBodyBytes) - return Promise.reject(new TypeError('Responses request body exceeds 8 MiB')) - const id = nextRequestId() - return new Promise((resolve, reject) => { - const operation: ModelOperation = { - id, - resolve, - reject, - signal: input.signal, - abort: undefined, - timer: undefined, - controller: undefined, - responseStarted: false, - pullPromise: undefined, - pullResolve: undefined, - pullReject: undefined, - pullSequence: 0, - pullId: undefined, - } - operation.abort = () => failModelOperation(operation, abortError(input.signal?.reason), true) - operation.timer = window.setTimeout(() => failModelOperation(operation, new Error('Model request exceeded 10 minutes'), true), modelRequestDeadlineMs) - modelOperations.set(id, operation) - input.signal?.addEventListener('abort', operation.abort, { once: true }) - post('models-request', id, { clientKeyId: input.clientKeyId, body }) - }) - } - document.addEventListener('click', (event) => { if (event.defaultPrevented || !(event.target instanceof Element)) return @@ -451,7 +206,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { if (typeof message.id !== 'number' || !Number.isSafeInteger(message.id) || message.id < 1) return if (message.type === 'models-response' || message.type === 'models-chunk' || message.type === 'models-complete' || message.type === 'models-error') { - handleModelMessage({ + modelBridge.handle({ id: message.id, type: message.type, error: message.error, @@ -488,20 +243,17 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { operation.reject(error) } pending.clear() - for (const operation of [ - ...modelOperations.values(), - ]) - failModelOperation(operation, error, true) + modelBridge.dispose() }, { once: true }) - const api = { + const api: PluginHostBridge = { version: initial.version, plugin: Object.freeze(initial.plugin), page: Object.freeze(initial.page), get theme() { return currentTheme }, request, callbackTicket, - models: Object.freeze({ responses: modelResponses }), + models: Object.freeze({ responses: modelBridge.responses }), resourceUrl(path: string) { const key = String(path) if (!Object.hasOwn(initial.resourceUrls, key)) diff --git a/frontend/src/views/plugins/management/model-streams.ts b/frontend/src/views/plugins/management/model-streams.ts new file mode 100644 index 000000000..4daf68413 --- /dev/null +++ b/frontend/src/views/plugins/management/model-streams.ts @@ -0,0 +1,268 @@ +import type { PluginBridgeMessage, PluginModelMessageType } from './bridge-contract' +import type { PluginManagementView } from '@/api' +import { callPluginModelResponses } from '@/api' +import { t } from '@/i18n' +import { errorMessage } from '@/utils/operation' +import { hasControlCharacter, isRecord, MAXIMUM_MODEL_BODY_BYTES, MAXIMUM_MODEL_CHUNK_BYTES, MAXIMUM_MODEL_IN_FLIGHT, MODEL_REQUEST_DEADLINE_MS, MODEL_RESPONSE_IDLE_MS } from './bridge-contract' + +interface ModelRequest { + id: number + source: Window + controller: AbortController + reader?: ReadableStreamDefaultReader + buffered?: Uint8Array + bufferedOffset: number + reading: boolean + nextPullId: number + activePullId?: number + totalTimer?: ReturnType + idleTimer?: ReturnType +} + +// 一个页面会话拥有全部模型流,页面销毁后迟到的响应只会被释放 +export function createPluginModelStreams(options: { + target: PluginManagementView['target'] + requestIds: ReadonlySet + post: (source: Window, type: PluginModelMessageType, id: number, payload?: unknown, error?: string, transfer?: Transferable[]) => void +}) { + const requests = new Map() + function validateModelRequestPayload(payload: unknown) { + if (!isRecord(payload)) + throw new Error(t('plugins.frame.invalidModelRequest')) + const clientKeyId = payload.clientKeyId + const body = payload.body + if ( + typeof clientKeyId !== 'string' + || !clientKeyId + || clientKeyId !== clientKeyId.trim() + || clientKeyId.length > 128 + || hasControlCharacter(clientKeyId) + ) { + throw new Error(t('plugins.frame.clientkeyidIsInvalid')) + } + if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > MAXIMUM_MODEL_BODY_BYTES) + throw new Error(t('plugins.frame.invalidModelBody')) + let parsed: unknown + try { + parsed = JSON.parse(body) + } + catch { + throw new Error(t('plugins.frame.invalidModelJson')) + } + if (!isRecord(parsed)) + throw new Error(t('plugins.frame.modelObjectRequired')) + return { clientKeyId, body } + } + + function supportedModelResponse(response: Response) { + if (!response.body) + return true + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + return contentType === 'application/json' + || contentType === 'text/event-stream' + || Boolean(contentType?.endsWith('+json')) + } + + function detachModelRequest(request: ModelRequest) { + if (requests.get(request.id) !== request) + return false + requests.delete(request.id) + clearTimeout(request.totalTimer) + clearTimeout(request.idleTimer) + request.totalTimer = undefined + request.idleTimer = undefined + return true + } + + function cancelModelReader(request: ModelRequest) { + const reader = request.reader + if (!reader) + return + void reader.cancel() + .finally(() => { + try { + reader.releaseLock() + } + catch { + // 取消中的 read 会自行释放;会话清理不能因此产生未处理拒绝。 + } + }) + .catch(() => undefined) + } + + function terminateModelRequest(request: ModelRequest, error?: string) { + if (!detachModelRequest(request)) + return + request.controller.abort() + cancelModelReader(request) + if (error) + options.post(request.source, 'models-error', request.id, undefined, error) + } + + function completeModelRequest(request: ModelRequest, pullId: number) { + if (!detachModelRequest(request)) + return + try { + request.reader?.releaseLock() + } + catch { + // EOF 已确定,锁清理失败不应阻断结束标记。 + } + options.post(request.source, 'models-complete', request.id, { pullId }) + } + + function armModelIdleTimeout(request: ModelRequest) { + clearTimeout(request.idleTimer) + request.idleTimer = setTimeout(() => { + terminateModelRequest(request, t('plugins.frame.pageDidNotContinueToReadModel')) + }, MODEL_RESPONSE_IDLE_MS) + } + + async function handleModelRequest(source: Window, message: PluginBridgeMessage) { + if (requests.size >= MAXIMUM_MODEL_IN_FLIGHT) { + options.post(source, 'models-error', message.id, undefined, t('plugins.frame.modelConcurrencyLimit')) + return + } + if (options.requestIds.has(message.id) || requests.has(message.id)) { + options.post(source, 'models-error', message.id, undefined, t('plugins.frame.duplicateModelRequest')) + return + } + + let payload: ReturnType + try { + payload = validateModelRequestPayload(message.payload) + } + catch (error) { + options.post(source, 'models-error', message.id, undefined, errorMessage(error)) + return + } + + const request: ModelRequest = { + id: message.id, + source, + controller: new AbortController(), + bufferedOffset: 0, + reading: false, + nextPullId: 1, + } + requests.set(message.id, request) + request.totalTimer = setTimeout(() => { + terminateModelRequest(request, t('plugins.frame.modelTimeout')) + }, MODEL_REQUEST_DEADLINE_MS) + + try { + const response = await callPluginModelResponses( + options.target, + payload.clientKeyId, + payload.body, + request.controller.signal, + ) + if (requests.get(request.id) !== request) + return + if (!supportedModelResponse(response)) + throw new Error(t('plugins.frame.unsupportedModelContentType')) + + const hasBody = response.body !== null + if (response.body) + request.reader = response.body.getReader() + const statusText = response.statusText.length <= 256 && !hasControlCharacter(response.statusText) + ? response.statusText + : '' + options.post(source, 'models-response', message.id, { + status: response.status, + statusText, + headers: Array.from(response.headers.entries()), + hasBody, + }) + if (!hasBody) { + detachModelRequest(request) + return + } + armModelIdleTimeout(request) + } + catch (error) { + if (requests.get(request.id) === request) + terminateModelRequest(request, errorMessage(error, t('plugins.frame.modelRequestFailed'))) + } + } + + async function handleModelPull(source: Window, message: PluginBridgeMessage) { + const request = requests.get(message.id) + if (!request || request.source !== source) + return + const pullId = isRecord(message.payload) + ? message.payload.pullId + : undefined + if (!Number.isSafeInteger(pullId) || Number(pullId) < 1) { + terminateModelRequest(request, t('plugins.frame.invalidPullId')) + return + } + if (request.reading) { + if (request.activePullId !== pullId) + terminateModelRequest(request, t('plugins.frame.concurrentPull')) + return + } + if (pullId !== request.nextPullId) { + terminateModelRequest(request, t('plugins.frame.stalePullId')) + return + } + clearTimeout(request.idleTimer) + request.idleTimer = undefined + request.reading = true + request.activePullId = Number(pullId) + try { + while (requests.get(message.id) === request) { + if (!request.buffered || request.bufferedOffset >= request.buffered.byteLength) { + request.buffered = undefined + request.bufferedOffset = 0 + const result = await request.reader?.read() + if (requests.get(message.id) !== request) + return + if (!result || result.done) { + completeModelRequest(request, Number(pullId)) + return + } + if (result.value.byteLength === 0) + continue + request.buffered = result.value + } + + const end = Math.min(request.bufferedOffset + MAXIMUM_MODEL_CHUNK_BYTES, request.buffered.byteLength) + const chunk = request.buffered.slice(request.bufferedOffset, end) + request.bufferedOffset = end + if (request.bufferedOffset >= request.buffered.byteLength) { + request.buffered = undefined + request.bufferedOffset = 0 + } + const body = chunk.buffer + request.nextPullId += 1 + options.post(source, 'models-chunk', message.id, { pullId, body }, undefined, [body]) + armModelIdleTimeout(request) + return + } + } + catch (error) { + if (requests.get(message.id) === request) + terminateModelRequest(request, errorMessage(error, t('plugins.frame.modelResponseReadFailed'))) + } + finally { + request.reading = false + request.activePullId = undefined + } + } + + return { + has: (id: number) => requests.has(id), + request: handleModelRequest, + pull: handleModelPull, + cancel(source: Window, id: number) { + const request = requests.get(id) + if (request?.source === source) + terminateModelRequest(request) + }, + dispose() { + for (const request of requests.values()) + terminateModelRequest(request) + }, + } +} diff --git a/frontend/src/views/plugins/utils/catalog.ts b/frontend/src/views/plugins/utils/catalog.ts index b94855be5..354a2c55c 100644 --- a/frontend/src/views/plugins/utils/catalog.ts +++ b/frontend/src/views/plugins/utils/catalog.ts @@ -8,7 +8,7 @@ export interface InstalledPlugin { source?: PluginUpdateSourceBinding } -export type PluginCatalogStatus = 'unaccepted' | 'unconfigured' | 'enabled' | 'disabled' | 'pending' | 'failed' +export type PluginCatalogStatus = 'unaccepted' | 'unconfigured' | 'enabled' | 'disabled' | 'pending' | 'failed' | 'unknown' export function groupInstalledPlugins(artifacts: PluginArtifact[], instances: PluginInstance[], sources: PluginUpdateSourceBinding[]): InstalledPlugin[] { const groups = new Map() @@ -51,6 +51,8 @@ export function configurationStatus(instance: PluginInstance): PluginCatalogStat return 'unconfigured' if (!instance.enabled) return 'disabled' + if (instance.runtime.status === 'unknown') + return 'unknown' if (['blocked', 'preparation_failed', 'faulted'].includes(instance.runtime.status)) return 'failed' return instance.runtime.status === 'running'