feat: 补齐扩展事务与撤销生命周期

This commit is contained in:
2026-09-11 21:18:52 +08:00
parent ae187a48ad
commit 4f3b67afcb
5 changed files with 295 additions and 9 deletions
@@ -146,6 +146,108 @@ pub async fn extension_install_preview(
.map_err(|_| "EXTENSION_PREVIEW_FAILED".to_string())?
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstallConfirmation {
request_id: String,
operation_id: String,
fingerprint: String,
root_key: String,
vault_id: String,
configurations: std::collections::BTreeMap<String, Value>,
}
#[tauri::command]
pub async fn extension_install_confirm(
window: WebviewWindow,
host: State<'_, Host>,
request: InstallConfirmation,
) -> Result<Value, String> {
main_window(&window)?;
let mut lease = host.extension_requests.claim(&request.request_id)?;
let checkpoint = lease.checkpoint();
let workspace = host.workspace.clone();
let extensions = host.extensions.clone();
tauri::async_runtime::spawn_blocking(move || {
let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?;
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != request.vault_id {
return Err("VAULT_CHANGED".into());
}
let install = InstallRequest {
root_key: request.root_key,
vault_id: request.vault_id,
app_version: env!("CARGO_PKG_VERSION").into(),
platform: std::env::consts::OS.into(),
architecture: std::env::consts::ARCH.into(),
configurations: request.configurations,
};
let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?;
let receipt = tauri::async_runtime::block_on(lease.run(async {
checkpoint()?;
store
.as_mut()
.ok_or("EXTENSIONS_NOT_READY")?
.install_confirmed(&request.operation_id, &install, &request.fingerprint)
.await
.map_err(|error| error.code)
}))?;
serde_json::to_value(receipt).map_err(|_| "EXTENSION_INSTALL_FAILED".into())
})
.await
.map_err(|_| "EXTENSION_INSTALL_FAILED".to_string())?
}
#[tauri::command]
pub async fn extension_install_rollback(
window: WebviewWindow,
host: State<'_, Host>,
operation_id: String,
installed_operation_id: String,
vault_id: String,
) -> Result<Value, String> {
main_window(&window)?;
let workspace = host.workspace.clone();
let extensions = host.extensions.clone();
tauri::async_runtime::spawn_blocking(move || {
let workspace = workspace.lock().map_err(|_| "HOST_BUSY")?;
if workspace.as_ref().ok_or("VAULT_NOT_OPEN")?.vault_id != vault_id {
return Err("VAULT_CHANGED".into());
}
let mut store = extensions.lock().map_err(|_| "HOST_BUSY")?;
let store = store.as_mut().ok_or("EXTENSIONS_NOT_READY")?;
let changes = store
.rollback_changes(&installed_operation_id)
.map_err(|error| error.code)?;
let receipt =
tauri::async_runtime::block_on(store.switch_online(&operation_id, &vault_id, &changes))
.map_err(|error| error.code)?;
serde_json::to_value(receipt).map_err(|_| "EXTENSION_ROLLBACK_FAILED".into())
})
.await
.map_err(|_| "EXTENSION_ROLLBACK_FAILED".to_string())?
}
#[tauri::command]
pub fn extension_uninstall(
window: WebviewWindow,
host: State<'_, Host>,
operation_id: String,
slot: String,
expected_revision: String,
) -> Result<Value, String> {
main_window(&window)?;
host.extension_authority.revoke();
#[cfg(windows)]
host.extension_instances
.lock()
.map_err(|_| "HOST_BUSY")?
.stop_all_and_join();
let receipt = store(&host, |s| {
s.uninstall_active(&operation_id, &slot, &expected_revision)
})?;
serde_json::to_value(receipt).map_err(|_| "EXTENSION_UNINSTALL_FAILED".into())
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StageRequest {
+25 -5
View File
@@ -372,14 +372,24 @@ impl Registry {
entry.endpoint.stop();
}
}
}
impl Drop for Registry {
fn drop(&mut self) {
/// 请求停止并等待所有实例释放工具、进程、容器和包 ACL。
pub fn stop_all_and_join(&mut self) {
self.stop_all();
for (_, entry) in std::mem::take(&mut self.entries) {
let _ = entry.worker.join();
}
}
pub fn active_count(&mut self) -> usize {
self.reap();
self.entries.len()
}
}
impl Drop for Registry {
fn drop(&mut self) {
self.stop_all_and_join();
}
}
fn run(spec: LaunchSpec, control: &Control, receiver: Receiver<Command>) -> Result<()> {
if control.stop.load(Ordering::Acquire) {
@@ -470,6 +480,9 @@ fn run_with_access(
.status
.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire);
while !control.stop.load(Ordering::Acquire) {
// 撤销、锁定和工作区切换必须在空闲实例上也能生效,不能等待下一次工具调用。
spec.authority
.verify(&spec.permit, &spec.claims, now_ms()?)?;
session.drain_pending()?;
if session.take_tools_changed() {
control
@@ -525,6 +538,7 @@ mod tests {
fn native_worker_routes_reviews_cancels_calls_and_reaps_generations() {
native_worker_lifecycle(false);
}
#[test]
#[ignore = "real background MCP CPU/memory/process exhaustion and restart; run explicitly"]
fn native_resource_failures_are_reaped_and_replacements_can_start() {
@@ -653,17 +667,23 @@ mod tests {
.code,
"EXTENSION_CALL_REVIEW_UNKNOWN"
);
endpoint.stop();
// 空闲实例也必须在许可撤销后自行退出并清空工具注册。
authority.revoke();
wait_for(|| {
registry.reap();
registry.entries.is_empty()
});
assert_eq!(
endpoint.snapshot().status,
Status::Stopped,
Status::Failed,
"{:?}",
endpoint.snapshot().error
);
assert_eq!(endpoint.snapshot().tool_count, 0);
assert_eq!(
endpoint.snapshot().error.as_deref(),
Some("EXTENSION_PERMIT_REVOKED")
);
assert!(endpoint.review("echo".into(), json!({})).is_err());
assert_eq!(
endpoint
+20 -3
View File
@@ -288,10 +288,10 @@ impl ExtensionStore {
let mut db = Connection::open(database)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 6 {
if version > 7 {
return Err(HostError::new("EXTENSION_SCHEMA_INCOMPATIBLE"));
}
if (1..6).contains(&version) {
if (1..7).contains(&version) {
let backup = root.join(format!(
"extensions.schema{version}.{}.sqlite3",
Uuid::new_v4()
@@ -308,8 +308,9 @@ impl ExtensionStore {
CREATE TABLE IF NOT EXISTS extension_trust(source TEXT NOT NULL,namespace TEXT NOT NULL,key_id TEXT NOT NULL,setting TEXT NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(source,namespace,key_id));
CREATE TABLE IF NOT EXISTS extension_blocks(identity TEXT PRIMARY KEY,reason TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS extension_confirmations(operation_id TEXT PRIMARY KEY,request_hash TEXT NOT NULL,review_hash TEXT NOT NULL,changes TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS legacy_installations(kind TEXT NOT NULL,package_id TEXT NOT NULL,source_path TEXT NOT NULL,expected_digest TEXT NOT NULL,observed_digest TEXT,ownership TEXT NOT NULL,state TEXT NOT NULL,enabled INTEGER NOT NULL CHECK(enabled=0),permissions TEXT NOT NULL CHECK(permissions='[]'),source_db_digest TEXT NOT NULL,PRIMARY KEY(kind,package_id));
PRAGMA user_version=6; COMMIT;")?;
PRAGMA user_version=7; COMMIT;")?;
crate::extension_transaction::recover(&mut db)?;
Ok(Self {
root,
@@ -849,6 +850,22 @@ impl ExtensionStore {
) -> Result<Option<crate::extension_transaction::Active>> {
crate::extension_transaction::active(&self.db, slot)
}
pub fn rollback_changes(
&self,
operation: &str,
) -> Result<Vec<crate::extension_transaction::Change>> {
crate::extension_transaction::rollback_changes(&self.db, operation)
}
pub fn uninstall_active(
&mut self,
operation: &str,
slot: &str,
expected_revision: &str,
) -> Result<crate::extension_transaction::Receipt> {
crate::extension_transaction::uninstall(&mut self.db, operation, slot, expected_revision)
}
/// 准备经过验证的暂存包。调用者提供当前的签名者/撤销策略;持久准备不会在重放时绕过该策略。
pub fn prepare(
&mut self,
+135 -1
View File
@@ -31,7 +31,8 @@ pub struct Receipt {
}
pub fn schema(db: &Connection) -> Result<()> {
db.execute_batch("CREATE TABLE IF NOT EXISTS extension_active(slot TEXT PRIMARY KEY,target TEXT NOT NULL,revision TEXT NOT NULL,pending_operation TEXT);
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);")?;
CREATE TABLE IF NOT EXISTS extension_transactions(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,before_state TEXT NOT NULL,after_state TEXT NOT NULL,state TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?;
Ok(())
}
pub fn active(db: &Connection, slot: &str) -> Result<Option<Active>> {
@@ -209,6 +210,95 @@ pub fn recover(db: &mut Connection) -> Result<usize> {
Ok(ids.len())
}
/// 为已完成的升级生成回滚变更;回滚本身仍以新的 operation_id 执行和记录。
pub fn rollback_changes(db: &Connection, operation: &str) -> Result<Vec<Change>> {
let (before, after, state): (String, String, String) = db
.query_row(
"SELECT before_state,after_state,state FROM extension_transactions WHERE id=?1",
[operation],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.map_err(|_| HostError::new("EXTENSION_ROLLBACK_UNKNOWN"))?;
if state != "complete" {
return Err(HostError::new("EXTENSION_ROLLBACK_INVALID"));
}
let old: Vec<Option<Active>> =
serde_json::from_str(&before).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
let installed: Vec<Change> =
serde_json::from_str(&after).map_err(|_| HostError::new("EXTENSION_STORE_CORRUPT"))?;
if old.len() != installed.len() || old.iter().any(Option::is_none) {
return Err(HostError::new("EXTENSION_ROLLBACK_INVALID"));
}
old.into_iter()
.zip(installed)
.map(|(previous, installed)| {
let previous = previous.unwrap();
let current = active(db, &installed.target.slot)?
.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?;
if current.pending_operation.is_some() || current.target != installed.target {
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
}
Ok(Change {
target: previous.target,
expected_revision: Some(current.revision),
})
})
.collect()
}
/// 实例停止后原子移除活动指针。保留已验证对象,以便审计或显式回滚;不会触碰外部目录。
pub fn uninstall(
db: &mut Connection,
operation: &str,
slot: &str,
expected_revision: &str,
) -> Result<Receipt> {
if uuid::Uuid::parse_str(operation).is_err()
|| slot.len() != 64
|| expected_revision.len() != 64
|| [slot, expected_revision].iter().any(|value| {
!value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
{
return Err(HostError::new("EXTENSION_TRANSACTION_INVALID"));
}
db.execute_batch("CREATE TABLE IF NOT EXISTS extension_uninstalls(id TEXT PRIMARY KEY,slot TEXT NOT NULL,expected_revision TEXT NOT NULL,state TEXT NOT NULL);")?;
let transaction = db.transaction()?;
let prior: Option<(String, String, String)> = transaction
.query_row(
"SELECT slot,expected_revision,state FROM extension_uninstalls WHERE id=?1",
[operation],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
if let Some((old_slot, old_revision, state)) = prior {
if old_slot != slot || old_revision != expected_revision {
return Err(HostError::new("OPERATION_REUSED"));
}
return Ok(Receipt {
operation_id: operation.into(),
state,
});
}
let current =
active(&transaction, slot)?.ok_or_else(|| HostError::new("EXTENSION_INSTALL_CONFLICT"))?;
if current.pending_operation.is_some() || current.revision != expected_revision {
return Err(HostError::new("EXTENSION_INSTALL_CONFLICT"));
}
transaction.execute(
"INSERT INTO extension_uninstalls VALUES (?1,?2,?3,'complete')",
params![operation, slot, expected_revision],
)?;
transaction.execute("DELETE FROM extension_active WHERE slot=?1", [slot])?;
transaction.commit()?;
Ok(Receipt {
operation_id: operation.into(),
state: "complete".into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -272,6 +362,50 @@ mod tests {
assert!(item.pending_operation.is_none());
}
}
#[test]
fn completed_upgrade_can_rollback_then_uninstall_idempotently() {
let temp = tempfile::tempdir().unwrap();
let mut db = open(&temp.path().join("state.sqlite3"));
let initial = change('a', 1, None);
let install = uuid::Uuid::new_v4().to_string();
switch(&mut db, &install, std::slice::from_ref(&initial)).unwrap();
finish(&mut db, &install, true).unwrap();
let installed = active(&db, &initial.target.slot).unwrap().unwrap();
let upgrade = change('a', 2, Some(installed.revision));
let upgrade_id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &upgrade_id, &[upgrade]).unwrap();
finish(&mut db, &upgrade_id, true).unwrap();
let rollback = rollback_changes(&db, &upgrade_id).unwrap();
let rollback_id = uuid::Uuid::new_v4().to_string();
switch(&mut db, &rollback_id, &rollback).unwrap();
finish(&mut db, &rollback_id, true).unwrap();
let restored = active(&db, &initial.target.slot).unwrap().unwrap();
assert_eq!(restored.target, initial.target);
let uninstall_id = uuid::Uuid::new_v4().to_string();
let receipt = uninstall(
&mut db,
&uninstall_id,
&restored.target.slot,
&restored.revision,
)
.unwrap();
assert_eq!(receipt.state, "complete");
assert_eq!(
uninstall(
&mut db,
&uninstall_id,
&restored.target.slot,
&restored.revision,
)
.unwrap(),
receipt
);
assert!(active(&db, &restored.target.slot).unwrap().is_none());
}
#[test]
fn group_switch_crashes_recover_matching_packages_and_configuration() {
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
+13
View File
@@ -31,6 +31,8 @@ struct Host {
extension_reviews: extension_commands::Reviews,
extension_requests: Requests,
extension_authority: notesagent_host::extension_permit::Authority,
#[cfg(windows)]
extension_instances: Mutex<notesagent_host::extension_instance::Registry>,
credential_signal: std::sync::OnceLock<Arc<std::sync::atomic::AtomicU64>>,
sync: Arc<sync_commands::Runtime>,
workspace: Arc<Mutex<Option<Workspace>>>,
@@ -45,12 +47,20 @@ struct Host {
impl Host {
fn replace_workspace(&self, active: &mut Option<Workspace>, next: Option<Workspace>) {
self.extension_authority.revoke();
#[cfg(windows)]
if let Ok(mut instances) = self.extension_instances.lock() {
instances.stop_all_and_join();
}
self.sync.cancel();
*active = next;
}
fn lock_credentials(&self) -> Result<(), String> {
// 这些不等待进行中解锁/KDF 或凭证操作。
self.extension_authority.revoke();
#[cfg(windows)]
if let Ok(mut instances) = self.extension_instances.lock() {
instances.stop_all_and_join();
}
if let Some(signal) = self.credential_signal.get() {
signal.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
@@ -1064,6 +1074,9 @@ fn main() {
extension_trust_confirm,
extension_trust_confirm_group,
extension_install_preview,
extension_install_confirm,
extension_install_rollback,
extension_uninstall,
extension_stage,
extension_stage_prepare,
extension_stage_cancel,