From 48d828a0e8e853be5248e802e3b1c1d10638785c Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 09:39:27 +0800 Subject: [PATCH] =?UTF-8?q?test(sync):=20=E7=99=BB=E8=AE=B0=20S-02=20?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/OpenNexus生产验收Runner.md | 2 +- frontend/src-tauri/src/sync_inbox.rs | 128 ++++++++++++++++++++ scripts/acceptance_cases/s02_sync_client.py | 126 +++++++++++++++++++ scripts/phase3_acceptance.py | 5 + 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 scripts/acceptance_cases/s02_sync_client.py diff --git a/docs/development/OpenNexus生产验收Runner.md b/docs/development/OpenNexus生产验收Runner.md index 00ff0d6..d987096 100644 --- a/docs/development/OpenNexus生产验收Runner.md +++ b/docs/development/OpenNexus生产验收Runner.md @@ -24,4 +24,4 @@ python scripts/phase3-production-acceptance.py ` 报告目录包含 `summary.json`、`case-manifest.json`、`junit.xml`、`cases/.json` 和脱敏的 `logs/.log`。摘要记录 commit、各锁文件 SHA-256、配置摘要与已提供安装产物摘要。日志将仓库、数据根、报告根、用户主目录和配置声明的秘密值替换为占位符,并限制为 10 MiB。报告目录必须为空,避免单例复跑覆盖原始证据。 -当前 runner 与失败闭合行为已实现,B-02 Fernet 迁移和 S-01 Sync 客户端 driver 已登记;其余 28 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 +当前 runner 与失败闭合行为已实现,B-02 Fernet 迁移以及 S-01、S-02 Sync 客户端 driver 已登记;其余 27 个生产验收 ID 尚未登记,运行时会生成 `NOT_IMPLEMENTED` 证据并退出 1。这用于阻止误报,不是这些用例的验收通过。 diff --git a/frontend/src-tauri/src/sync_inbox.rs b/frontend/src-tauri/src/sync_inbox.rs index ef44653..d2bf0e3 100644 --- a/frontend/src-tauri/src/sync_inbox.rs +++ b/frontend/src-tauri/src/sync_inbox.rs @@ -378,6 +378,7 @@ impl Workspace { #[cfg(test)] mod tests { use super::*; + use std::fs; #[test] fn inbox_reopen_before_and_after_file_commit_never_advances_cursor_early() { for committed in [false, true] { @@ -430,4 +431,131 @@ mod tests { assert!(!ws.sync_apply_pending(&binding.id).unwrap()); } } + + fn s02_revision(file_id: &str) -> RemoteRevision { + RemoteRevision { + vault_id: "remote-vault".into(), + sequence: 1, + file_id: file_id.into(), + base_revision: 0, + path: "nested/a.md".into(), + operation: "put".into(), + hash: Some(hash(b"remote-content")), + size: 14, + operation_id: Uuid::new_v4().to_string(), + } + } + + #[test] + #[ignore = "helper process killed by the S-02 parent at a durable pull boundary"] + fn s02_pull_boundary_worker() { + let root = std::path::PathBuf::from( + std::env::var("OPENNEXUS_S02_ROOT").expect("controlled S-02 root"), + ); + let boundary = std::env::var("OPENNEXUS_S02_BOUNDARY").expect("S-02 boundary"); + let revision: RemoteRevision = serde_json::from_slice( + &fs::read(root.join(".s02-revision.json")).expect("S-02 revision fixture"), + ) + .unwrap(); + let mut ws = Workspace::open(&root).unwrap(); + let binding = ws.sync_binding().unwrap().unwrap(); + ws.sync_store_bytes(b"remote-content").unwrap(); + if boundary != "spool" { + ws.sync_set_boundary(&binding.id, 1).unwrap(); + ws.sync_stage(&binding.id, &revision).unwrap(); + } + if boundary == "file" { + let operation: String = ws + .db + .query_row("SELECT operation_id FROM sync_inbox", [], |row| row.get(0)) + .unwrap(); + ws.write_with_identity( + "nested/a.md", + "", + b"remote-content", + "remote", + &operation, + Some(&revision.file_id), + ) + .unwrap(); + } else if boundary == "cursor" { + assert!(ws.sync_apply_pending(&binding.id).unwrap()); + } + fs::write(root.join(".s02-boundary-ready"), boundary).unwrap(); + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + } + + #[test] + #[ignore = "80 real process kills; run through the S-02 production acceptance driver"] + fn s02_pull_each_persistence_boundary_survives_twenty_process_kills() { + use std::process::{Command, Stdio}; + for boundary in ["spool", "stage", "file", "cursor"] { + for round in 0..20 { + let root = tempfile::tempdir().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let binding = ws + .sync_bind_download("https://sync.example", "remote-vault", "account") + .unwrap(); + let file_id = Uuid::new_v4().to_string(); + let revision = s02_revision(&file_id); + fs::write( + root.path().join(".s02-revision.json"), + serde_json::to_vec(&revision).unwrap(), + ) + .unwrap(); + drop(ws); + let mut worker = Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "sync_inbox::tests::s02_pull_boundary_worker", + ]) + .env("OPENNEXUS_S02_ROOT", root.path()) + .env("OPENNEXUS_S02_BOUNDARY", boundary) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let marker = root.path().join(".s02-boundary-ready"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !marker.exists() { + assert!( + worker.try_wait().unwrap().is_none(), + "S-02 {boundary} worker exited in round {round}" + ); + assert!( + std::time::Instant::now() < deadline, + "S-02 {boundary} timeout in round {round}" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + worker.kill().unwrap(); + worker.wait().unwrap(); + let mut ws = Workspace::open(root.path()).unwrap(); + let expected_cursor = i64::from(boundary == "cursor"); + assert_eq!( + ws.sync_binding().unwrap().unwrap().cursor, + expected_cursor, + "cursor advanced early at {boundary} round {round}" + ); + if expected_cursor == 0 { + ws.sync_store_bytes(b"remote-content").unwrap(); + ws.sync_set_boundary(&binding.id, 1).unwrap(); + ws.sync_stage(&binding.id, &revision).unwrap(); + while ws.sync_apply_pending(&binding.id).unwrap() {} + } else { + assert!(!ws.sync_apply_pending(&binding.id).unwrap()); + } + assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 1); + let document = ws.read("nested/a.md").unwrap(); + assert_eq!(document.content, "remote-content"); + assert_eq!(document.entry.file_id, file_id); + assert_eq!(document.entry.hash, hash(b"remote-content")); + assert_eq!(ws.pending_count().unwrap(), 0); + } + } + } } diff --git a/scripts/acceptance_cases/s02_sync_client.py b/scripts/acceptance_cases/s02_sync_client.py new file mode 100644 index 0000000..3ebb134 --- /dev/null +++ b/scripts/acceptance_cases/s02_sync_client.py @@ -0,0 +1,126 @@ +"""S-02 resumable attachment and pull-boundary crash driver.""" + +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" +UPLOAD_TEST = "s01_actual_service_preserves_offline_chains_and_response_loss_idempotency" +PULL_TEST = "sync_inbox::tests::s02_pull_each_persistence_boundary_survives_twenty_process_kills" + + +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(command: list[str]) -> bool: + completed = subprocess.run(command, 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 result(case_id: str, status: str, reason: str, assertions: list[dict]) -> dict: + evidence = f"cargo exact tests {UPLOAD_TEST} and {PULL_TEST}" + for assertion in assertions: + assertion.update({"status": status, "evidence": evidence}) + files = [] + for relative in ( + "frontend/src-tauri/src/sync_inbox.rs", + "frontend/src-tauri/tests/sync_push.rs", + "server sync/tests/host_fixture.py", + ): + files.append({"path": relative, "sha256": sha256(ROOT / relative)}) + return { + "schema": 1, + "case_id": case_id, + "status": status, + "reason": reason, + "assertions": assertions, + "metrics": {"peak_rss_bytes": None, "max_process_count": None, "denied_access_count": None}, + "files": files, + "revisions": [ + {"scope": "resumable attachment", "size_bytes": 104857600, "process_kills": 10}, + {"scope": "pull persistence boundaries", "boundary_count": 4, "kills_per_boundary": 20}, + ], + } + + +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": "100 MiB upload resumes after a process kill at every 10 MiB durable offset"}, + {"name": "resumed attachment length and SHA-256 equal the source"}, + {"name": "pull spool, stage, file, and cursor boundaries each survive twenty process kills"}, + {"name": "pull cursor never advances beyond the materialized revision"}, + {"name": "remote materialization creates no local outbox operation"}, + {"name": "attachment bodies remain outside SQLite metadata storage"}, + ] + cargo = shutil.which("cargo") + if case_id != "S-02" or cargo is None: + payload = result(case_id, "FAILED", "S-02 requires the registered case ID and Cargo.", assertions) + output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 1 + + pull_passed = run_exact( + [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--lib", + PULL_TEST, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + ) + upload_passed = run_exact( + [ + cargo, + "test", + "--manifest-path", + str(MANIFEST), + "--locked", + "--features", + "desktop", + "--test", + "sync_push", + UPLOAD_TEST, + "--", + "--exact", + "--nocapture", + ] + ) + passed = pull_passed and upload_passed + payload = result( + case_id, + "PASSED" if passed else "FAILED", + "" if passed else "At least one exact Rust S-02 acceptance oracle failed.", + assertions, + ) + 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()) diff --git a/scripts/phase3_acceptance.py b/scripts/phase3_acceptance.py index 83e95bf..bd7ef9e 100644 --- a/scripts/phase3_acceptance.py +++ b/scripts/phase3_acceptance.py @@ -42,6 +42,11 @@ CASE_DRIVERS: dict[str, dict[str, Any]] = { "timeout_seconds": 900, "required_metrics": (), }, + "S-02": { + "driver": "scripts/acceptance_cases/s02_sync_client.py", + "timeout_seconds": 900, + "required_metrics": (), + }, } ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]{2,127}") RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{2,63}")