feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -213,6 +213,12 @@ pub fn recover(db: &mut Connection) -> Result<usize> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
path::Path,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
fn open(path: &std::path::Path) -> Connection {
|
fn open(path: &std::path::Path) -> Connection {
|
||||||
let db = Connection::open(path).unwrap();
|
let db = Connection::open(path).unwrap();
|
||||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
|
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")
|
||||||
@@ -232,6 +238,41 @@ mod tests {
|
|||||||
expected_revision: previous,
|
expected_revision: previous,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn seeded(path: &Path) -> (Vec<Change>, Vec<Change>) {
|
||||||
|
let mut db = open(path);
|
||||||
|
let old = vec![change('a', 1, None), change('b', 1, None)];
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
switch(&mut db, &id, &old).unwrap();
|
||||||
|
finish(&mut db, &id, true).unwrap();
|
||||||
|
let next = old
|
||||||
|
.iter()
|
||||||
|
.map(|current| {
|
||||||
|
change(
|
||||||
|
current.target.slot.chars().next().unwrap(),
|
||||||
|
2,
|
||||||
|
Some(active(&db, ¤t.target.slot).unwrap().unwrap().revision),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(old, next)
|
||||||
|
}
|
||||||
|
fn assert_complete_generation(db: &Connection) {
|
||||||
|
let active: Vec<_> = ['a', 'b']
|
||||||
|
.into_iter()
|
||||||
|
.map(|slot| active(db, &slot.to_string().repeat(64)).unwrap().unwrap())
|
||||||
|
.collect();
|
||||||
|
let versions: BTreeSet<_> = active
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.target.configuration["version"].as_u64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(versions.len(), 1, "package/config generations were mixed");
|
||||||
|
let version = *versions.first().unwrap();
|
||||||
|
assert!(matches!(version, 1 | 2));
|
||||||
|
for item in active {
|
||||||
|
assert_eq!(item.target.package_key, format!("{version:x}").repeat(64));
|
||||||
|
assert!(item.pending_operation.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn group_switch_crashes_recover_matching_packages_and_configuration() {
|
fn group_switch_crashes_recover_matching_packages_and_configuration() {
|
||||||
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
|
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
|
||||||
@@ -275,6 +316,176 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
|
#[ignore = "parent acceptance oracle hard-terminates this helper at a durable boundary"]
|
||||||
|
fn power_cut_worker() {
|
||||||
|
let Some(path) = std::env::var_os("OPENNEXUS_D03_DATABASE") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let boundary = std::env::var("OPENNEXUS_D03_BOUNDARY").unwrap();
|
||||||
|
let marker = std::path::PathBuf::from(std::env::var_os("OPENNEXUS_D03_MARKER").unwrap());
|
||||||
|
let mut db = open(Path::new(&path));
|
||||||
|
let old: Vec<_> = ['a', 'b']
|
||||||
|
.into_iter()
|
||||||
|
.map(|slot| {
|
||||||
|
let current = active(&db, &slot.to_string().repeat(64)).unwrap().unwrap();
|
||||||
|
Change {
|
||||||
|
target: current.target.clone(),
|
||||||
|
expected_revision: Some(current.revision),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let next: Vec<_> = old
|
||||||
|
.iter()
|
||||||
|
.map(|current| {
|
||||||
|
change(
|
||||||
|
current.target.slot.chars().next().unwrap(),
|
||||||
|
2,
|
||||||
|
current.expected_revision.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let operation = uuid::Uuid::new_v4().to_string();
|
||||||
|
let _ = switch_inner(&mut db, &operation, &next, |at| {
|
||||||
|
if at == boundary {
|
||||||
|
let file = std::fs::File::create(&marker).unwrap();
|
||||||
|
file.sync_all().unwrap();
|
||||||
|
loop {
|
||||||
|
thread::sleep(Duration::from_secs(60));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
panic!("power-cut helper passed the requested boundary");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn group_switch_survives_hard_termination_twenty_times_per_boundary() {
|
||||||
|
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
|
||||||
|
for round in 0..20 {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("state.sqlite3");
|
||||||
|
seeded(&path);
|
||||||
|
let marker = temp.path().join(format!("{boundary}-{round}.ready"));
|
||||||
|
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args([
|
||||||
|
"--ignored",
|
||||||
|
"--exact",
|
||||||
|
"extension_transaction::tests::power_cut_worker",
|
||||||
|
"--nocapture",
|
||||||
|
])
|
||||||
|
.env("OPENNEXUS_D03_DATABASE", &path)
|
||||||
|
.env("OPENNEXUS_D03_BOUNDARY", boundary)
|
||||||
|
.env("OPENNEXUS_D03_MARKER", &marker)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let started = Instant::now();
|
||||||
|
while !marker.is_file() {
|
||||||
|
assert!(
|
||||||
|
child.try_wait().unwrap().is_none(),
|
||||||
|
"helper exited before {boundary}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(10),
|
||||||
|
"helper did not reach {boundary}"
|
||||||
|
);
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
child.kill().unwrap();
|
||||||
|
assert!(!child.wait().unwrap().success());
|
||||||
|
let mut db = open(&path);
|
||||||
|
recover(&mut db).unwrap();
|
||||||
|
assert_complete_generation(&db);
|
||||||
|
assert_eq!(recover(&mut db).unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn disk_full_and_configuration_migration_failures_cover_every_boundary() {
|
||||||
|
let mapped: HostError = rusqlite::Error::SqliteFailure(
|
||||||
|
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
assert_eq!(mapped.code, "QUOTA_EXCEEDED");
|
||||||
|
for failure in ["QUOTA_EXCEEDED", "EXTENSION_CONFIG_MIGRATION_FAILED"] {
|
||||||
|
for boundary in ["journal_recorded", "pointer_recorded", "switch_committed"] {
|
||||||
|
for _ in 0..20 {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("state.sqlite3");
|
||||||
|
let (_, next) = seeded(&path);
|
||||||
|
let mut db = open(&path);
|
||||||
|
let update = uuid::Uuid::new_v4().to_string();
|
||||||
|
let error = switch_inner(&mut db, &update, &next, |at| {
|
||||||
|
if at == boundary {
|
||||||
|
Err(HostError::new(failure))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code, failure);
|
||||||
|
drop(db);
|
||||||
|
let mut db = open(&path);
|
||||||
|
recover(&mut db).unwrap();
|
||||||
|
assert_complete_generation(&db);
|
||||||
|
assert_eq!(recover(&mut db).unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn failed_switch_cannot_expand_an_existing_execution_permit() {
|
||||||
|
use crate::extension_permit::{Authority, Claims, Environment, ExecutionKind};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
let authority = Authority::default();
|
||||||
|
let mut claims = Claims {
|
||||||
|
kind: ExecutionKind::Mcp,
|
||||||
|
source: "https://catalog.example/".into(),
|
||||||
|
namespace: "examples".into(),
|
||||||
|
package_id: "note-reviewer".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
archive_sha256: "a".repeat(64),
|
||||||
|
tree_sha256: "b".repeat(64),
|
||||||
|
signer_sha256: "c".repeat(64),
|
||||||
|
entry: "entry.exe".into(),
|
||||||
|
arguments: vec!["--stdio".into()],
|
||||||
|
environment: BTreeMap::from([(
|
||||||
|
"MODE".into(),
|
||||||
|
Environment::Literal("production".into()),
|
||||||
|
)]),
|
||||||
|
permissions: BTreeSet::from(["notes.read".into()]),
|
||||||
|
vault_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
platform: "windows".into(),
|
||||||
|
policy_version: "1".into(),
|
||||||
|
expires_at_ms: 10_000,
|
||||||
|
};
|
||||||
|
let permit = authority.issue(&claims, 1).unwrap();
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("state.sqlite3");
|
||||||
|
let (_, next) = seeded(&path);
|
||||||
|
let mut db = open(&path);
|
||||||
|
let error = switch_inner(&mut db, &uuid::Uuid::new_v4().to_string(), &next, |at| {
|
||||||
|
if at == "switch_committed" {
|
||||||
|
Err(HostError::new("EXTENSION_CONFIG_MIGRATION_FAILED"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.code, "EXTENSION_CONFIG_MIGRATION_FAILED");
|
||||||
|
recover(&mut db).unwrap();
|
||||||
|
authority.verify(&permit, &claims, 2).unwrap();
|
||||||
|
claims.permissions.insert("notes.write".into());
|
||||||
|
assert_eq!(
|
||||||
|
authority.verify(&permit, &claims, 2).unwrap_err().code,
|
||||||
|
"PERMISSION_CHANGED"
|
||||||
|
);
|
||||||
|
assert_complete_generation(&db);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
fn cas_busy_replay_and_failed_first_install() {
|
fn cas_busy_replay_and_failed_first_install() {
|
||||||
let mut db = Connection::open_in_memory().unwrap();
|
let mut db = Connection::open_in_memory().unwrap();
|
||||||
schema(&db).unwrap();
|
schema(&db).unwrap();
|
||||||
|
|||||||
@@ -33,7 +33,19 @@ impl From<std::io::Error> for HostError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl From<rusqlite::Error> for HostError {
|
impl From<rusqlite::Error> for HostError {
|
||||||
fn from(_: rusqlite::Error) -> Self {
|
fn from(error: rusqlite::Error) -> Self {
|
||||||
|
if matches!(
|
||||||
|
error,
|
||||||
|
rusqlite::Error::SqliteFailure(
|
||||||
|
rusqlite::ffi::Error {
|
||||||
|
code: rusqlite::ErrorCode::DiskFull,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
_
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return Self::new("QUOTA_EXCEEDED");
|
||||||
|
}
|
||||||
Self::new("DATABASE_ERROR")
|
Self::new("DATABASE_ERROR")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""D-03 crash, storage, configuration, dependency, and permission acceptance."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
MANIFEST = ROOT / "frontend" / "src-tauri" / "Cargo.toml"
|
||||||
|
TESTS = (
|
||||||
|
(
|
||||||
|
"extension_transaction::tests::group_switch_survives_hard_termination_twenty_times_per_boundary",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"extension_transaction::tests::disk_full_and_configuration_migration_failures_cover_every_boundary",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"extension_transaction::tests::failed_switch_cannot_expand_an_existing_execution_permit",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"extension_dependencies::tests::cycles_missing_versions_and_incompatible_platforms_fail",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"extension_store::tests::prepared_switch_rechecks_content_vault_binding_and_recovers_on_open",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def run_exact(cargo: str, test: str, ignored: bool) -> bool:
|
||||||
|
arguments = [
|
||||||
|
cargo,
|
||||||
|
"test",
|
||||||
|
"--manifest-path",
|
||||||
|
str(MANIFEST),
|
||||||
|
"--locked",
|
||||||
|
"--features",
|
||||||
|
"desktop",
|
||||||
|
"--lib",
|
||||||
|
test,
|
||||||
|
"--",
|
||||||
|
]
|
||||||
|
if ignored:
|
||||||
|
arguments.append("--ignored")
|
||||||
|
arguments.extend(("--exact", "--nocapture", "--test-threads=1"))
|
||||||
|
completed = subprocess.run(
|
||||||
|
arguments,
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
print(completed.stdout, end="")
|
||||||
|
print(completed.stderr, end="")
|
||||||
|
return completed.returncode == 0 and "1 passed; 0 failed" in completed.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--config", required=True)
|
||||||
|
parser.add_argument("--output", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
case_id = os.environ.get("OPENNEXUS_ACCEPTANCE_CASE_ID", "")
|
||||||
|
output = Path(args.output)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
"name": "three durable switch boundaries survive 20 actual hard terminations each",
|
||||||
|
"evidence": "60 child test processes were killed only after the requested SQLite boundary",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "disk-full and configuration-migration failures cover every boundary for 20 rounds",
|
||||||
|
"evidence": "120 injected failures reopened and recovered to one complete package/config generation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dependency cycle, missing package, and version conflict fail during immutable planning",
|
||||||
|
"evidence": "three exact dependency error codes are returned before any active pointer exists",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "failed installation cannot expand a previously issued execution permit",
|
||||||
|
"evidence": "old permit remains bound to notes.read and rejects added notes.write as PERMISSION_CHANGED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prepared switch rejects invalid configuration and recovers pending state on reopen",
|
||||||
|
"evidence": "configuration rejection creates zero transactions; reopen rolls pending switch back idempotently",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
cargo = shutil.which("cargo")
|
||||||
|
passed = case_id == "D-03" and cargo is not None and os.name == "nt"
|
||||||
|
if passed:
|
||||||
|
passed = all(run_exact(cargo, test, ignored) for test, ignored in TESTS)
|
||||||
|
status = "PASSED" if passed else "FAILED"
|
||||||
|
for assertion in assertions:
|
||||||
|
assertion["status"] = status
|
||||||
|
files = []
|
||||||
|
for relative in (
|
||||||
|
"frontend/src-tauri/src/extension_transaction.rs",
|
||||||
|
"frontend/src-tauri/src/extension_dependencies.rs",
|
||||||
|
"frontend/src-tauri/src/extension_store.rs",
|
||||||
|
"frontend/src-tauri/src/extension_permit.rs",
|
||||||
|
"frontend/src-tauri/src/workspace.rs",
|
||||||
|
"scripts/acceptance_cases/d03_extension_transactions.py",
|
||||||
|
):
|
||||||
|
files.append({"path": relative, "sha256": sha256(ROOT / relative)})
|
||||||
|
payload = {
|
||||||
|
"schema": 1,
|
||||||
|
"case_id": case_id,
|
||||||
|
"status": status,
|
||||||
|
"reason": "" if passed else "A D-03 exact transaction oracle failed.",
|
||||||
|
"assertions": assertions,
|
||||||
|
"metrics": {
|
||||||
|
"power_cut_rounds": 60 if passed else 0,
|
||||||
|
"disk_full_rounds": 60 if passed else 0,
|
||||||
|
"configuration_failure_rounds": 60 if passed else 0,
|
||||||
|
"dependency_preflight_rejections": 3 if passed else 0,
|
||||||
|
"permission_expansions": 0,
|
||||||
|
"peak_rss_bytes": None,
|
||||||
|
"max_process_count": None,
|
||||||
|
"denied_access_count": None,
|
||||||
|
},
|
||||||
|
"files": files,
|
||||||
|
"revisions": [
|
||||||
|
{"scope": "transaction boundaries", "values": ["journal_recorded", "pointer_recorded", "switch_committed"]},
|
||||||
|
{"scope": "recovered generations", "values": ["complete-old", "complete-new"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -68,6 +68,18 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = {
|
|||||||
"timeout_seconds": 900,
|
"timeout_seconds": 900,
|
||||||
"required_metrics": (),
|
"required_metrics": (),
|
||||||
},
|
},
|
||||||
|
"D-03": {
|
||||||
|
"driver": "scripts/acceptance_cases/d03_extension_transactions.py",
|
||||||
|
"timeout_seconds": 900,
|
||||||
|
"required_metrics": (
|
||||||
|
"power_cut_rounds",
|
||||||
|
"disk_full_rounds",
|
||||||
|
"configuration_failure_rounds",
|
||||||
|
"dependency_preflight_rejections",
|
||||||
|
"permission_expansions",
|
||||||
|
),
|
||||||
|
"platform_profiles": ("windows-11-x64",),
|
||||||
|
},
|
||||||
"S-01": {
|
"S-01": {
|
||||||
"driver": "scripts/acceptance_cases/s01_sync_client.py",
|
"driver": "scripts/acceptance_cases/s01_sync_client.py",
|
||||||
"timeout_seconds": 900,
|
"timeout_seconds": 900,
|
||||||
|
|||||||
Reference in New Issue
Block a user