mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
committed by
Zyy
co-authored by
Zyy
Codex
parent
e38cf31323
commit
1d1ccda42a
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
-43
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -69,7 +69,8 @@ flowchart LR
|
||||
声明自动维护的插件可自行准备分组和 Key,无需提供管理员密码。停用会停止维护,但保留这些资源及其原生启用状态;
|
||||
删除插件配置也不自动删除资源。需要撤销 Key 访问时,在 Key 管理中停用或删除相应 Key
|
||||
|
||||
“已启用”表示配置已经发布,不是外部服务的健康证明。“等待生效”表示候选仍在准备;“需要处理”会显示发布或运行失败原因。
|
||||
“已启用”表示配置匹配且宿主观察到会话就绪,不是插件业务或外部服务的健康证明。“等待生效”表示候选仍在准备或等待发布;“需要处理”会显示发布或运行失败原因。
|
||||
“运行状态未知”表示暂时没有运行诊断,启用配置保持不变。详情中的“运行负载”展示当前发布会话的在途调用、执行、排队、恢复等待、驻留回调和活动流;计数可能重叠,不包含其他正在排空的旧版本
|
||||
并发编辑冲突时应重新加载最新配置后确认,不能覆盖另一窗口的修改
|
||||
|
||||
## 使用
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "活动流"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user