fix(sandbox): 容器不可用自动降级 + clean_expired 并发回归测试
P1-1 降级机制:
- sandbox 新增 DEGRADED 全局标志,ensure_container 失败时置位
- 新增 ensure_ready_or_degrade: exec 前检测容器运行状态,
不在则重建,重建失败置降级标志并返回 false
- executor 调用 ensure_ready_or_degrade,降级时回退本地执行
(审批已在入口完成,安全边界不受影响)
- spawn_child 保留 spawn 级失败兜底(docker 二进制缺失等)
- 解决: IAS_DOCKER_SANDBOX=1 但 docker 异常时工具不再持续 SpawnFailed
P1-2 并发回归测试:
- approval 新增 create_with_ttl 测试入口(cfg(test))
- clean_expired_only_removes_expired_entries: 验证只删过期条目
- clean_expired_safe_when_no_pending: 空 pending 不 panic
- clean_expired_skips_entries_already_removed_by_reply:
回归保护——clean_expired 第一阶段收集 hash 后、第二阶段删除前,
条目被 handle_reply 提前移除时,按 hash 查找应安全跳过不误删
P2-5 容器崩溃恢复:
- ensure_ready_or_degrade 统一处理容器崩溃重建
- recover_if_crashed 保留供 spawn 级失败兜底调用
P2-6 build_exec_cmd 用 spec.tool 替代 path 文件名提取,消除隐式耦合
验证: 28 测试通过(原25+3),clippy 22<基线23 无新增警告,
端到端验证容器重建/降级/本地三种路径均正确。
This commit is contained in:
@@ -129,6 +129,33 @@ impl ApprovalManager {
|
||||
Ok((code_str, rx))
|
||||
}
|
||||
|
||||
/// 测试专用:创建指定 TTL(秒)的审批请求
|
||||
#[cfg(test)]
|
||||
async fn create_with_ttl(
|
||||
&self,
|
||||
user_id: &str,
|
||||
skill_name: &str,
|
||||
ttl_secs: u64,
|
||||
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), String> {
|
||||
let code: u32 = rand::rng().random_range(100000..999999);
|
||||
let code_str = code.to_string();
|
||||
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
let mut map = self.pending.lock().await;
|
||||
map.entry(user_id.to_string())
|
||||
.or_default()
|
||||
.push(PendingEntry {
|
||||
code_hash,
|
||||
skill_name: skill_name.to_string(),
|
||||
attempts_left: 3,
|
||||
expires_at: Instant::now() + std::time::Duration::from_secs(ttl_secs),
|
||||
sender: tx,
|
||||
});
|
||||
|
||||
Ok((code_str, rx))
|
||||
}
|
||||
|
||||
/// 处理用户回复(由消息循环调用)
|
||||
/// 返回 (技能名, 决策);匹配失败返回 None
|
||||
pub async fn handle_reply(
|
||||
@@ -241,3 +268,66 @@ impl ApprovalManager {
|
||||
map.retain(|_, v| !v.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clean_expired_only_removes_expired_entries() {
|
||||
// 无 DB,纯内存
|
||||
let mgr = ApprovalManager::new(None);
|
||||
|
||||
// 两条审批:一条立即过期(ttl=0),一条 300s 不过期
|
||||
let (_expired_code, expired_rx) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap();
|
||||
let (live_code, live_rx) = mgr.create_with_ttl("u1", "tool_b", 300).await.unwrap();
|
||||
|
||||
// 立即过期的条目 expires_at 已成过去,sleep 确保时间推进
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
mgr.clean_expired().await;
|
||||
|
||||
// 过期条目应收到 Expired
|
||||
assert_eq!(expired_rx.await.unwrap(), ApprovalDecision::Expired);
|
||||
// 未过期条目不应被误删:handle_reply 用正确确认码应能匹配
|
||||
let (name, decision) = mgr.handle_reply("u1", &live_code).await.unwrap();
|
||||
assert_eq!(name, "tool_b");
|
||||
assert_eq!(decision, ApprovalDecision::Approved);
|
||||
// live_rx 应收到 Approved(未被 clean_expired 误标记)
|
||||
assert_eq!(live_rx.await.unwrap(), ApprovalDecision::Approved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clean_expired_safe_when_no_pending() {
|
||||
// 空 pending 不应 panic
|
||||
let mgr = ApprovalManager::new(None);
|
||||
mgr.clean_expired().await;
|
||||
// 重复调用也安全
|
||||
mgr.clean_expired().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clean_expired_skips_entries_already_removed_by_reply() {
|
||||
// 回归保护:clean_expired 第一阶段收集过期 hash 后、第二阶段删除前,
|
||||
// 若该条目已被 handle_reply 提前处理,clean_expired 应安全跳过,不误删其他条目。
|
||||
// (原 bug:按快照 index 删除会导致误删;现按 code_hash 删除应安全跳过)
|
||||
let mgr = ApprovalManager::new(None);
|
||||
|
||||
// 两条同用户审批,都设为立即过期
|
||||
let (_code_a, rx_a) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap();
|
||||
let (_code_b, rx_b) = mgr.create_with_ttl("u1", "tool_b", 0).await.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
// 先用 handle_reply “0” 取消最早的条目(tool_a),模拟并发回复
|
||||
let (name, decision) = mgr.handle_reply("u1", "0").await.unwrap();
|
||||
assert_eq!(name, "tool_a");
|
||||
assert_eq!(decision, ApprovalDecision::Rejected);
|
||||
assert_eq!(rx_a.await.unwrap(), ApprovalDecision::Rejected);
|
||||
|
||||
// 再 clean_expired:tool_a 已被 remove,按 hash 查找应跳过;
|
||||
// tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过)
|
||||
mgr.clean_expired().await;
|
||||
assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user