refactor(plugins): consolidate host lifecycle and diagnostics (#529)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-26 17:06:44 +08:00
committed by Zyy
co-authored by Zyy Codex
parent e38cf31323
commit 1d1ccda42a
59 changed files with 3706 additions and 2748 deletions
@@ -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<String>,
pub failure: Option<PluginInstanceRuntimeFailure>,
pub draining_revisions: Vec<u64>,
pub load: Option<PluginInstanceRuntimeLoad>,
}
/// 当前发布会话的资源占用采样,计数可以重叠且不构成原子快照
#[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,
}
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
@@ -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<String>,
load_error: Option<String>,
}
impl PluginsService {
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
async fn package_status(&self, digest: &str) -> Result<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
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<Vec<PluginInstanceView>, 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::<BTreeMap<_, _>>();
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<PluginInstanceMutation, AdminError> {
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<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
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<PluginInstanceMutation, AdminError> {
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<Revision>,
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<Revision, AdminError> {
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<PluginInstanceMutation, AdminError> {
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::<usize>()
> 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::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.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::<BTreeSet<_>>()
.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, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -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::<usize>()
> 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::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.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::<BTreeSet<_>>()
.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, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -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<PluginInstanceMutation, AdminError> {
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<Revision>,
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("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
}
}
}
}
@@ -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<String>,
pub(super) load_error: Option<String>,
}
@@ -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<PluginInstanceMutation, AdminError> {
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<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
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<Revision, AdminError> {
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<PluginInstanceMutation, AdminError> {
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)
}
}
@@ -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(())
}
}
@@ -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<PackageStatus, AdminError> {
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
// 只缓存有限数量的确定结果,暂时性错误仍可重试
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<Vec<PluginInstanceView>, 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::<BTreeMap<_, _>>();
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)
}
}
@@ -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<Option<String>, 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<FixtureData>,
next_prepared: AtomicU64,
inspection_gate: Mutex<Option<Arc<InspectionGate>>>,
}
struct InspectionGate {
digest: String,
started: tokio::sync::Notify,
release: tokio::sync::Notify,
}
impl LifecycleFixture {
fn new(behavior: MigrationBehavior) -> Arc<Self> {
Arc::new(Self {
inspection_gate: Mutex::new(None),
behavior,
data: Mutex::new(FixtureData {
snapshot: PluginInstanceSnapshot {
@@ -970,49 +985,6 @@ fn state_unavailable<T>() -> PluginStateStoreResult<T> {
))
}
#[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);
@@ -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);
}
@@ -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<PluginInstanceRuntimeStatus> 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<String>,
failure: Option<InstanceRuntimeFailure>,
draining_revisions: Vec<u64>,
load: Option<InstanceRuntimeLoad>,
}
#[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<PluginInstanceRuntimeLoad> 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<PluginInstanceRuntime> for InstanceRuntimeView {
@@ -128,6 +155,7 @@ impl From<PluginInstanceRuntime> 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),
}
}
}
@@ -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,
}),
},
)
})
@@ -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?)
}
@@ -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<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
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",
))
})
}
}
@@ -139,7 +139,7 @@ impl HttpCallbacks<'_> {
) -> Result<RpcReply, PluginFault> {
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());
}
@@ -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<Self>,
resource_scope_id: String,
invocation: Arc<dyn MiddlewareCallback>,
scope: Arc<CallbackScope>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
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<dyn MiddlewareCallback>,
) {
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<Arc<gateway_core::engine::nested::ExecutionEffects>>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
cancellation: CancellationToken,
request_body_limit: Option<usize>,
) -> Result<Arc<CallbackScope>, 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<usize, PluginFault> {
self.request_limits.current_body_bytes()
}
pub(crate) fn prepare_upstream(
&self,
context: &CallContext,
managed: Arc<upstream::ManagedUpstream>,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
request_body_limit: usize,
) -> Result<Arc<CallbackScope>, 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<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, 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<Arc<CallbackScope>, 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<Arc<CallbackScope>, 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<gateway_plugin_sdk::call::host::AuthSaveResult, PluginFault> {
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<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, 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<Arc<CallbackScope>, 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<Self>,
call: &CallContext,
origin: Option<InvocationContext>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
invocation: Arc<dyn MiddlewareCallback>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
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<Arc<CallbackScope>, 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)
}
}
@@ -52,7 +52,7 @@ impl PluginClientKeys {
params: serde_json::Value,
payload: &[u8],
) -> Result<RpcReply, PluginFault> {
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!({}) {
@@ -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<services::ServicePorts>,
pub(crate) http: Arc<HttpClient>,
pub(crate) accounts: Arc<PluginAccountPortSlot>,
pub(crate) keys: Arc<PluginClientKeyPortSlot>,
pub(crate) clients: Arc<PluginClientIdentityPortSlot>,
pub(crate) models: Arc<PluginModelPortSlot>,
pub(crate) affinity: Arc<PluginAffinityPortSlot>,
pub(crate) resources: Arc<PluginResourcePorts>,
}
pub(crate) struct PluginCallbacks {
pub(crate) registration_body_limit: Option<usize>,
services: Arc<services::ServicePorts>,
service_actor: gateway_admin::model::usage_data::UsageActor,
accounts: Arc<accounts::PluginAccounts>,
resources: Arc<resources::PluginResources>,
data: Arc<data::PluginData>,
keys: Arc<keys::PluginClientKeys>,
affinity: Arc<affinity::PluginAffinity>,
log: Arc<log::PluginLog>,
clients: Arc<client::PluginClients>,
models: Arc<model::PluginModels>,
private_state: Arc<private_state::PluginPrivateState>,
http: Arc<HttpClient>,
network: Arc<NetworkPolicy>,
scopes: Mutex<BTreeMap<String, Weak<CallbackScope>>>,
pending_middleware: Mutex<BTreeMap<String, Arc<dyn MiddlewareCallback>>>,
calls: Mutex<BTreeMap<u64, Arc<CallResources>>>,
maximum_payload: usize,
request_limits: Arc<request_limits::RequestLimits>,
}
impl PluginCallbacks {
pub(crate) fn new(
instance: &gateway_admin::model::plugins::instances::PluginInstance,
maximum_payload: usize,
manifest: &gateway_plugin_sdk::Manifest,
log_slots: Arc<tokio::sync::Semaphore>,
private_state: Arc<private_state::PluginPrivateState>,
ports: PluginCallbackPorts,
request_limits: Arc<request_limits::RequestLimits>,
) -> Result<Self, AdminError> {
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<Self>,
resource_scope_id: String,
invocation: Arc<dyn MiddlewareCallback>,
scope: Arc<CallbackScope>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
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<dyn MiddlewareCallback>) {
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<Arc<gateway_core::engine::nested::ExecutionEffects>>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
cancellation: CancellationToken,
request_body_limit: Option<usize>,
) -> Result<Arc<CallbackScope>, 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<usize, PluginFault> {
self.request_limits.current_body_bytes()
}
pub(crate) fn prepare_upstream(
&self,
context: &CallContext,
managed: Arc<upstream::ManagedUpstream>,
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
request_body_limit: usize,
) -> Result<Arc<CallbackScope>, 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<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, 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<Arc<CallbackScope>, 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<Arc<CallbackScope>, 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<gateway_plugin_sdk::call::host::AuthSaveResult, PluginFault> {
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<OutboundProxy>,
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
) -> Result<Arc<CallbackScope>, 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<Arc<CallbackScope>, 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<Self>,
call: &CallContext,
origin: Option<InvocationContext>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
invocation: Arc<dyn MiddlewareCallback>,
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
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<Arc<CallbackScope>, 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<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
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(
@@ -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<u8>,
) -> Result<RpcReply, PluginFault> {
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());
}
@@ -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<services::ServicePorts>,
pub(crate) http: Arc<HttpClient>,
pub(crate) accounts: Arc<PluginAccountPortSlot>,
pub(crate) keys: Arc<PluginClientKeyPortSlot>,
pub(crate) clients: Arc<PluginClientIdentityPortSlot>,
pub(crate) models: Arc<PluginModelPortSlot>,
pub(crate) affinity: Arc<PluginAffinityPortSlot>,
pub(crate) resources: Arc<PluginResourcePorts>,
}
pub(crate) struct PluginCallbacks {
pub(crate) registration_body_limit: Option<usize>,
pub(super) services: Arc<services::ServicePorts>,
pub(super) service_actor: gateway_admin::model::usage_data::UsageActor,
pub(super) accounts: Arc<accounts::PluginAccounts>,
pub(super) resources: Arc<resources::PluginResources>,
pub(super) data: Arc<data::PluginData>,
pub(super) keys: Arc<keys::PluginClientKeys>,
pub(super) affinity: Arc<affinity::PluginAffinity>,
pub(super) log: Arc<log::PluginLog>,
pub(super) clients: Arc<client::PluginClients>,
pub(super) models: Arc<model::PluginModels>,
pub(super) private_state: Arc<private_state::PluginPrivateState>,
pub(super) http: Arc<HttpClient>,
pub(super) network: Arc<NetworkPolicy>,
pub(super) scopes: Mutex<BTreeMap<String, Weak<CallbackScope>>>,
pub(super) pending_middleware: Mutex<BTreeMap<String, Arc<dyn MiddlewareCallback>>>,
pub(super) calls: Mutex<BTreeMap<u64, Arc<CallResources>>>,
pub(super) maximum_payload: usize,
pub(super) request_limits: Arc<request_limits::RequestLimits>,
}
impl PluginCallbacks {
pub(crate) fn new(
instance: &gateway_admin::model::plugins::instances::PluginInstance,
maximum_payload: usize,
manifest: &gateway_plugin_sdk::Manifest,
log_slots: Arc<tokio::sync::Semaphore>,
private_state: Arc<private_state::PluginPrivateState>,
ports: PluginCallbackPorts,
request_limits: Arc<request_limits::RequestLimits>,
) -> Result<Self, AdminError> {
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,
})
}
}
@@ -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)?;
@@ -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());
}
@@ -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,
}
}),
}
}
}
@@ -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(),
)
}
}
@@ -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<State>,
started_at: Instant,
pub(super) expected_stop: AtomicBool,
failure_observer: Option<Arc<dyn Fn(Duration) + Send + Sync>>,
pub control: mpsc::Sender<EncodedFrame>,
pub process: ProcessControl,
pub stopped: watch::Sender<bool>,
callback_slots: Arc<Semaphore>,
pub(super) execution: ExecutionCapacity,
cancellations_changed: Notify,
}
struct State {
failure: Option<RpcError>,
calls: BTreeMap<u64, PendingCall>,
}
struct PendingCall {
_slot: Arc<OwnedSemaphorePermit>,
execution: Option<OwnedSemaphorePermit>,
deadline: CallDeadline,
handler: Arc<dyn CallbackHandler>,
transmitted: bool,
cancellation: Option<RpcError>,
context: CallContext,
result: Option<oneshot::Sender<Result<RpcReply, RpcError>>>,
stream: Option<StreamIngress>,
scheduling: Option<Scheduling>,
callbacks: BTreeMap<u64, CallbackTask>,
}
// 调度状态只属于父任务;回调完成与恢复授权分别推进
enum Scheduling {
Running {
epoch: u64,
},
Suspended {
epoch: u64,
pause: Option<CallDeadlinePause>,
},
Resuming {
epoch: u64,
pause: Option<CallDeadlinePause>,
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<RpcReply, RpcError>) {
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<EncodedFrame>,
failure_observer: Option<Arc<dyn Fn(Duration) + Send + Sync>>,
) -> 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<StreamIngress>,
slot: Arc<OwnedSemaphorePermit>,
execution: OwnedSemaphorePermit,
handler: Arc<dyn CallbackHandler>,
) -> Result<StartedCall, RpcError> {
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<RpcReply, RpcError>) -> 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<u8>) -> 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<PluginFault>) -> 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<CallContext> {
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<Self>, 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<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(());
}
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<OwnedSemaphorePermit> {
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<Result<RpcReply, RpcError>>,
}
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 调用超时"
);
}
@@ -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<gateway_core::error::ErrorSource>),
#[error("plugin call budget negotiation failed: {0}")]
Budget(#[from] CallBudgetError),
#[error("plugin protocol is invalid")]
Protocol(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an invalid response at {0:?}")]
InvalidResponse(Stage, #[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin process or transport has stopped")]
Closed(#[source] Option<gateway_core::error::ErrorSource>),
#[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<gateway_core::error::ErrorSource>),
#[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<dyn Fn(Duration) + Send + Sync>,
end: Option<Box<dyn FnOnce() + Send + Sync>>,
}
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<u8>,
}
/// 具体回调在领域适配器校验账号、出站与执行身份;上下文只取自宿主在途调用表
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<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>>;
}
@@ -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<W, R>(
writer: W,
@@ -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;
@@ -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<gateway_core::error::ErrorSource>),
#[error("plugin call budget negotiation failed: {0}")]
Budget(#[from] CallBudgetError),
#[error("plugin protocol is invalid")]
Protocol(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an invalid response at {0:?}")]
InvalidResponse(Stage, #[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin process or transport has stopped")]
Closed(#[source] Option<gateway_core::error::ErrorSource>),
#[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<gateway_core::error::ErrorSource>),
#[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<dyn Fn(Duration) + Send + Sync>,
end: Option<Box<dyn FnOnce() + Send + Sync>>,
}
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<u8>,
}
/// 具体回调在领域适配器校验账号、出站与执行身份;上下文只取自宿主在途调用表
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<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>>;
}
pub struct RpcSession {
shared: Arc<Shared>,
handshake: Handshake,
@@ -216,473 +43,6 @@ impl RpcSession {
}
}
pub(crate) struct Shared {
state: Mutex<State>,
started_at: Instant,
expected_stop: AtomicBool,
failure_observer: Option<Arc<dyn Fn(Duration) + Send + Sync>>,
pub control: mpsc::Sender<EncodedFrame>,
pub process: ProcessControl,
pub stopped: watch::Sender<bool>,
callback_slots: Arc<Semaphore>,
execution: ExecutionCapacity,
cancellations_changed: Notify,
}
struct State {
failure: Option<RpcError>,
calls: BTreeMap<u64, PendingCall>,
}
struct PendingCall {
_slot: Arc<OwnedSemaphorePermit>,
execution: Option<OwnedSemaphorePermit>,
deadline: CallDeadline,
handler: Arc<dyn CallbackHandler>,
transmitted: bool,
cancellation: Option<RpcError>,
context: CallContext,
result: Option<oneshot::Sender<Result<RpcReply, RpcError>>>,
stream: Option<StreamIngress>,
scheduling: Option<Scheduling>,
callbacks: BTreeMap<u64, CallbackTask>,
}
// 调度状态只属于父任务;回调完成与恢复授权分别推进
enum Scheduling {
Running {
epoch: u64,
},
Suspended {
epoch: u64,
pause: Option<CallDeadlinePause>,
},
Resuming {
epoch: u64,
pause: Option<CallDeadlinePause>,
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<RpcReply, RpcError>) {
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<RpcReply, RpcError>) -> 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<u8>) -> 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<PluginFault>) -> 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<CallContext> {
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<Self>, 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<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(());
}
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<OwnedSemaphorePermit> {
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<StreamIngress>,
slot: Arc<OwnedSemaphorePermit>,
execution: OwnedSemaphorePermit,
) -> Result<StartedCall, RpcError> {
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<Result<RpcReply, RpcError>>,
}
struct CompletedCall {
slot: Arc<OwnedSemaphorePermit>,
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 调用超时"
);
}
@@ -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<Value> = 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;
}
@@ -1,5 +1,6 @@
//! 插件宿主回调的测试入口
mod dispatch;
mod key_facts;
mod keys;
mod log;
@@ -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();
@@ -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);
@@ -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;
}
}
@@ -1,5 +1,6 @@
//! 插件双向 RPC 分派与会话生命周期的测试入口
mod admission;
mod calls;
mod dispatch;
mod session;
@@ -31,7 +31,7 @@ struct BlockingCallbacks {
#[derive(Default)]
pub(super) struct LifecycleCallbacks {
pub(super) begun: AtomicUsize,
finished: AtomicUsize,
pub(super) finished: AtomicUsize,
}
struct OversizedReplyCallbacks;
@@ -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` 最多调用一次,重试与额外模型执行通过独立调用创建
@@ -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;
@@ -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 {
@@ -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<u8>,
) -> Result<HostHttpResponse, PluginFault> {
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<u8>,
) -> Result<HostHttpResponse, PluginFault> {
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(),
@@ -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(),
)
@@ -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<UpstreamWebSocketUpgrade, PluginFault> {
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![],
)
+22 -1
View File
@@ -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
+6
View File
@@ -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#声明调用预算)
+2 -1
View File
@@ -69,7 +69,8 @@ flowchart LR
声明自动维护的插件可自行准备分组和 Key,无需提供管理员密码。停用会停止维护,但保留这些资源及其原生启用状态;
删除插件配置也不自动删除资源。需要撤销 Key 访问时,在 Key 管理中停用或删除相应 Key
“已启用”表示配置已经发布,不是外部服务的健康证明。“等待生效”表示候选仍在准备;“需要处理”会显示发布或运行失败原因。
“已启用”表示配置匹配且宿主观察到会话就绪,不是插件业务或外部服务的健康证明。“等待生效”表示候选仍在准备或等待发布;“需要处理”会显示发布或运行失败原因。
“运行状态未知”表示暂时没有运行诊断,启用配置保持不变。详情中的“运行负载”展示当前发布会话的在途调用、执行、排队、恢复等待、驻留回调和活动流;计数可能重叠,不包含其他正在排空的旧版本
并发编辑冲突时应重新加载最新配置后确认,不能覆盖另一窗口的修改
## 使用
+11
View File
@@ -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 {
+18 -2
View File
@@ -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"
}
}
}
+18 -2
View File
@@ -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": "活动流"
}
}
}
+18 -2
View File
@@ -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": "活動流"
}
}
}
@@ -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)"
/>
<PluginRuntimeLoad v-if="instance.runtime.load" :load="instance.runtime.load" :revision="instance.runtime.actualRevision" />
<div class="flex flex-wrap items-center gap-2">
<div v-if="configurationStatus(instance) === 'enabled'" class="mr-auto flex min-w-0 flex-wrap items-center gap-2">
<RouterLink v-for="page in viewByInstance.get(instance.id)?.pages ?? []" :key="page.id" :to="pluginPageLocation(viewByInstance.get(instance.id)!, page)" class="inline-flex h-cp-control-sm min-w-0 items-center gap-1.5 rounded-cp bg-cp-primary-container px-2.5 text-cp-sm leading-none text-cp-primary-on-container no-underline outline-none transition-colors hover:bg-cp-primary-container-hover focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none" @click="open = false">
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { PluginInstanceRuntimeLoad } from '@/api'
import { ChevronDown } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PluginHelpPopover from './PluginHelpPopover.vue'
const props = defineProps<{ load: PluginInstanceRuntimeLoad, revision: number | null }>()
const { t } = useI18n({ useScope: 'global' })
const values = computed(() => [
{
label: t('plugins.runtime.activeCalls'),
value: props.load.activeCalls,
},
{
label: t('plugins.runtime.executingCalls'),
value: props.load.executingCalls,
},
{
label: t('plugins.runtime.queuedCalls'),
value: props.load.queuedCalls,
},
{
label: t('plugins.runtime.resumingCalls'),
value: props.load.resumingCalls,
},
{
label: t('plugins.runtime.pendingCallbacks'),
value: props.load.pendingCallbacks,
},
{
label: t('plugins.runtime.activeStreams'),
value: props.load.activeStreams,
},
])
const summary = computed(() => values.value.every(item => item.value === 0)
? t('plugins.runtime.idle')
: t('plugins.runtime.inFlightSummary', { count: props.load.activeCalls }))
</script>
<template>
<section class="flex items-start gap-2 text-cp-xs" :aria-label="t('plugins.runtime.title')">
<details class="group min-w-0 flex-1">
<summary class="flex cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-cp py-1 text-cp-text-secondary outline-none hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline active:text-cp-primary-text [&::-webkit-details-marker]:hidden">
<span>{{ t('plugins.runtime.title') }}</span>
<span class="inline-flex items-center gap-2">
{{ summary }}
<ChevronDown class="size-3.5 shrink-0 transition-transform group-open:rotate-180 motion-reduce:transition-none" aria-hidden="true" />
</span>
</summary>
<dl class="m-0 mt-2 grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3">
<div v-for="item in values" :key="item.label" class="flex min-w-0 items-baseline gap-2">
<dt class="text-cp-text-secondary">
{{ item.label }}
</dt>
<dd class="m-0 shrink-0 font-mono text-cp-text tabular-nums">
{{ item.value }}
</dd>
</div>
</dl>
</details>
<PluginHelpPopover :label="t('plugins.runtime.helpTitle')" class="mt-0.5">
<p v-if="revision !== null" class="m-0 font-emphasis text-cp-text">
{{ t('plugins.runtime.revision', { revision }) }}
</p>
<p class="m-0">
{{ t('plugins.runtime.description') }}
</p>
</PluginHelpPopover>
</section>
</template>
@@ -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<number>
modelRequests: Map<number, ModelRequest>
models: ReturnType<typeof createPluginModelStreams>
loaded: boolean
}
interface ModelRequest {
id: number
source: Window
controller: AbortController
reader?: ReadableStreamDefaultReader<Uint8Array>
buffered?: Uint8Array
bufferedOffset: number
reading: boolean
nextPullId: number
activePullId?: number
totalTimer?: ReturnType<typeof setTimeout>
idleTimer?: ReturnType<typeof setTimeout>
}
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<number>()
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<string, unknown>
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<string, unknown> {
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<typeof validateModelRequestPayload>
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)
}
}
@@ -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
+1
View File
@@ -88,4 +88,5 @@ export const PLUGIN_STATUS_LABELS = computed<Record<PluginCatalogStatus, string>
disabled: t('plugins.fields.deactivated'),
pending: t('plugins.fields.waitingForItToTakeEffect'),
failed: t('common.status.error'),
unknown: t('plugins.status.unknown'),
}))
@@ -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<string, unknown>
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<string, unknown>
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<PluginManagementReply>
callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise<PluginCallbackTicket>
resourceUrl: (path: string) => string
readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise<Response> }>
}
export interface PluginFrameTheme {
name: 'light' | 'dark'
tokens: Record<string, string>
@@ -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<string, unknown> {
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
@@ -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<Uint8Array<ArrayBuffer>>
responseStarted: boolean
pullPromise?: Promise<void>
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<number, ModelOperation>()
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<void>((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<Response> {
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)
},
}
}
@@ -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<Uint8Array<ArrayBuffer>>
responseStarted: boolean
pullPromise?: Promise<void>
pullResolve?: () => void
pullReject?: (error: Error) => void
pullSequence: number
pullId?: number
}
interface ModelMessage {
id: number
type: string
payload?: Record<string, unknown>
error?: unknown
}
interface ManagementRequest {
method: string
path: string
query?: string
contentType?: string
body?: string | ArrayBuffer | ArrayBufferView
}
interface ModelRequest {
clientKeyId: string
body: Record<string, unknown>
signal?: AbortSignal
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
export default function installPluginBridge(initial: PluginBridgeInitial) {
const pending = new Map<number, PendingOperation>()
const modelOperations = new Map<number, ModelOperation>()
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<unknown> {
const modelBridge = createPluginModelBridge({
nextRequestId,
post,
isDisposed: () => disposed,
})
function invoke<T>(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise<T> {
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<PluginManagementReply>('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<PluginCallbackTicket>('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<void>((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<Response> {
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))
@@ -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<Uint8Array>
buffered?: Uint8Array
bufferedOffset: number
reading: boolean
nextPullId: number
activePullId?: number
totalTimer?: ReturnType<typeof setTimeout>
idleTimer?: ReturnType<typeof setTimeout>
}
// 一个页面会话拥有全部模型流,页面销毁后迟到的响应只会被释放
export function createPluginModelStreams(options: {
target: PluginManagementView['target']
requestIds: ReadonlySet<number>
post: (source: Window, type: PluginModelMessageType, id: number, payload?: unknown, error?: string, transfer?: Transferable[]) => void
}) {
const requests = new Map<number, ModelRequest>()
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<typeof validateModelRequestPayload>
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)
},
}
}
+3 -1
View File
@@ -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<string, InstalledPlugin>()
@@ -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'