diff --git a/docs/README.md b/docs/README.md index 8f4a1e3..797fa38 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # OpenNexus 文档索引 -最新:[2026-09-08 验收报告与 Sync 测试部署](development/OpenNexus验收报告-2026-09-08.md)。全量验收未通过,含已复现阻塞项与逐 ID 结果。 +最新:[2026-09-08 验收修复与全量回归](development/OpenNexus验收修复与回归-2026-09-08.md)。两项已复现缺陷已修复,现有自动化测试全部通过;完整生产化验收仍未通过。原始证据见[验收报告与 Sync 测试部署](development/OpenNexus验收报告-2026-09-08.md)。 > 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。 diff --git a/docs/contracts/Host-v1契约.md b/docs/contracts/Host-v1契约.md index 4e693db..f6bafb5 100644 --- a/docs/contracts/Host-v1契约.md +++ b/docs/contracts/Host-v1契约.md @@ -9,7 +9,9 @@ | 命令 | 参数 / 返回 | | --- | --- | | `host_capabilities` | protocol=1,workspace/credentials=true;core 随进程状态返回;sync/extensions=false | -| `core_request` | 代理认证 Sidecar 的 `/health` 与 `/api/*`;Host 注入会话和代际,支持 JSON 与限额二进制,不接受前端鉴权头 | +| `core_request_prepare` | timeout_ms(1–600000),返回一次性 request_id;最多 64 个待执行/在途请求,默认前端超时 30000 ms | +| `core_request` | request 对象含 requestId、method、path、body/bodyBase64、contentType、idempotencyKey;必须使用未过期的预登记 ID 且仅执行一次。代理认证 Sidecar 的 `/health` 与 `/api/*`,不接受前端鉴权头 | +| `core_request_cancel` | request_id;取消未发送的预登记项或终止在途 HTTP Future,重复取消无副作用 | | `workspace_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name | | `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 | | `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 | @@ -21,7 +23,9 @@ | `workspace_delete` | path、expected;正文保存在 `.ainote/trash` | | `workspace_mkdir` | path,相对当前授权 Vault | -当前错误通过 Tauri rejection 返回稳定 code,前端 `DesktopError` 保留该 code。主要 code 为 REVISION_CONFLICT、RECOVERY_CONFLICT、UNSAFE_PATH、PATH_CONFLICT、VAULT_ALREADY_OPEN、SCHEMA_INCOMPATIBLE、ATOMIC_REPLACE_FAILED。统一 request_id、取消标识及完整错误详情尚未固化。 +当前错误通过 Tauri rejection 返回稳定 code,前端 `DesktopError` 保留该 code。主要 code 为 REVISION_CONFLICT、RECOVERY_CONFLICT、UNSAFE_PATH、PATH_CONFLICT、VAULT_ALREADY_OPEN、SCHEMA_INCOMPATIBLE、ATOMIC_REPLACE_FAILED。常规 Core 请求新增 REQUEST_CANCELLED、REQUEST_TIMEOUT 及预登记错误;前端取消/超时错误带 request_id 和 outcome(not_sent/unknown)。已经发送的写入不能推断回滚,不自动重试;按业务 operation_id 确认提交结果的完整链路仍待实现。 + +凭据保险库解锁前取得独立 `.lock` 文件的 OS 排他锁,持有至手动锁定、错误后锁定或实例销毁。其他实例返回 CREDENTIALS_BUSY,不读取旧快照后继续覆盖写入;错误口令释放临时锁,进程退出由 OS 释放。Windows 禁止在持锁期间替换该锁文件。 ## 写入及恢复 diff --git a/docs/development/OpenNexus验收修复与回归-2026-09-08.json b/docs/development/OpenNexus验收修复与回归-2026-09-08.json new file mode 100644 index 0000000..399a7c9 --- /dev/null +++ b/docs/development/OpenNexus验收修复与回归-2026-09-08.json @@ -0,0 +1,271 @@ +{ + "date": "2026-09-08", + "base_commit": "6bf5127", + "known_defects": { + "F-01": "FIXED", + "F-02": "FIXED" + }, + "automated_regression": "PASSED", + "production_acceptance": "NOT_PASSED", + "counts": { + "backend": { + "tests": 894, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "packaged-core": { + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "sync": { + "tests": 23, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "community": { + "tests": 13, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "frontend": { + "tests": 504, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "rust": { + "passed": 28 + } + }, + "checks": [ + { + "name": "rust-fmt", + "command": [ + "cargo", + "fmt", + "--manifest-path", + "frontend/src-tauri/Cargo.toml", + "--check" + ], + "exit_code": 0, + "seconds": 0.383, + "finished_utc": "2026-09-08T05:09:44.693513+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "rust-tests", + "command": [ + "cargo", + "test", + "--manifest-path", + "frontend/src-tauri/Cargo.toml", + "--features", + "desktop", + "--all-targets", + "--locked" + ], + "exit_code": 0, + "seconds": 39.22, + "finished_utc": "2026-09-08T05:10:23.925087+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "rust-clippy", + "command": [ + "cargo", + "clippy", + "--manifest-path", + "frontend/src-tauri/Cargo.toml", + "--features", + "desktop", + "--all-targets", + "--locked", + "--", + "-D", + "warnings" + ], + "exit_code": 0, + "seconds": 3.526, + "finished_utc": "2026-09-08T05:10:27.480493+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "backend", + "command": [ + "uv", + "run", + "--directory", + "backend", + "pytest", + "-q", + "--junitxml=G:\\OSProject\\NotesAgent-phase3\\.build\\acceptance\\fixes-6bf5127\\backend.xml" + ], + "exit_code": 0, + "seconds": 199.768, + "finished_utc": "2026-09-08T05:06:18.641433+00:00", + "artifact_directory": ".build\\acceptance\\fixes-6bf5127" + }, + { + "name": "packaged-core", + "command": [ + "uv", + "run", + "--directory", + "backend", + "pytest", + "-q", + "tests/test_sidecar_auth.py", + "--junitxml=G:\\OSProject\\NotesAgent-phase3\\.build\\acceptance\\fixes-6bf5127\\packaged-core.xml" + ], + "exit_code": 0, + "seconds": 3.742, + "finished_utc": "2026-09-08T05:06:22.396738+00:00", + "artifact_directory": ".build\\acceptance\\fixes-6bf5127" + }, + { + "name": "frontend", + "command": [ + "pnpm", + "--dir", + "frontend", + "exec", + "vitest", + "run", + "--maxWorkers=2", + "--reporter=default", + "--reporter=junit", + "--outputFile.junit=G:\\OSProject\\NotesAgent-phase3\\.build\\acceptance\\fixes-final-6bf5127\\frontend.xml" + ], + "exit_code": 0, + "seconds": 56.45, + "finished_utc": "2026-09-08T05:10:40.765672+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "types", + "command": [ + "pnpm", + "--dir", + "frontend", + "type-check" + ], + "exit_code": 0, + "seconds": 7.819, + "finished_utc": "2026-09-08T05:10:48.619856+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "build", + "command": [ + "pnpm", + "--dir", + "frontend", + "build" + ], + "exit_code": 0, + "seconds": 34.154, + "finished_utc": "2026-09-08T05:11:22.812302+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "sync", + "command": [ + "uv", + "run", + "--directory", + "server sync", + "pytest", + "-q", + "--junitxml=G:\\OSProject\\NotesAgent-phase3\\.build\\acceptance\\fixes-6bf5127\\sync.xml" + ], + "exit_code": 0, + "seconds": 9.874, + "finished_utc": "2026-09-08T05:08:10.342428+00:00", + "artifact_directory": ".build\\acceptance\\fixes-6bf5127" + }, + { + "name": "community", + "command": [ + "uv", + "run", + "--directory", + "community-server", + "pytest", + "-q", + "--junitxml=G:\\OSProject\\NotesAgent-phase3\\.build\\acceptance\\fixes-6bf5127\\community.xml" + ], + "exit_code": 0, + "seconds": 3.162, + "finished_utc": "2026-09-08T05:08:13.520864+00:00", + "artifact_directory": ".build\\acceptance\\fixes-6bf5127" + }, + { + "name": "smoke", + "command": [ + "G:\\OSProject\\NotesAgent-phase3\\backend\\.venv\\Scripts\\python.exe", + "scripts/phase3-isolated-smoke.py" + ], + "exit_code": 0, + "seconds": 3.411, + "finished_utc": "2026-09-08T05:08:16.933393+00:00", + "artifact_directory": ".build\\acceptance\\fixes-6bf5127" + }, + { + "name": "docs", + "command": [ + "python", + "scripts/check-doc-links.py" + ], + "exit_code": 0, + "seconds": 7.01, + "finished_utc": "2026-09-08T05:11:29.830898+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + }, + { + "name": "diff", + "command": [ + "git", + "diff", + "--check", + "--", + ".", + ":(exclude)backend/data/vault" + ], + "exit_code": 0, + "seconds": 0.119, + "finished_utc": "2026-09-08T05:11:29.989493+00:00", + "artifact_directory": ".build\\acceptance\\fixes-final-6bf5127" + } + ], + "remote_health": { + "checked_at": 1788844300.226044, + "url": "http://yui.kronecker.cc:18080", + "checks": [ + { + "path": "/health", + "status": 200, + "expected": 200 + }, + { + "path": "/ready", + "status": 200, + "expected": 200 + }, + { + "path": "/sync/v1/handshake", + "status": 200, + "expected": 200 + }, + { + "path": "/sync/v1/vaults", + "status": 401, + "expected": 401 + } + ] + } +} diff --git a/docs/development/OpenNexus验收修复与回归-2026-09-08.md b/docs/development/OpenNexus验收修复与回归-2026-09-08.md new file mode 100644 index 0000000..79d0dee --- /dev/null +++ b/docs/development/OpenNexus验收修复与回归-2026-09-08.md @@ -0,0 +1,42 @@ +# OpenNexus 验收修复与全量回归(2026-09-08) + +本次在 `feat/phase3-completion` 工作树、基线 `6bf5127` 上修复了原验收中复现的 F-01、F-02,并完成现有自动化测试的全量回归。原始失败记录保留在[首次验收报告](OpenNexus验收报告-2026-09-08.md);本报告不将尚未实现或缺少证据的生产化验收目标标记为通过。 + +## 修复内容 + +### F-01:多个凭据 Broker 覆盖快照 + +Stronghold 在读取快照前取得同目录独立锁文件的操作系统排他锁,并持有至凭据库锁定。第二个实例返回 `CREDENTIALS_BUSY`,设置页提供可理解的提示。锁文件独立于原子替换的快照文件;Windows 禁止其他句柄删除被持有的锁文件。解锁失败或进程退出时释放所有权。 + +回归测试验证两个实例交接后两次写入均保留、错误密码不遗留锁,以及真实子进程持锁时拒绝并发解锁、子进程被终止后能重新解锁并读取已提交凭据。 + +### F-02:桌面请求忽略取消和超时 + +前端和 Rust Host 使用先预留、后派发的请求协议。已取消请求不会派发;等待预留期间取消后,迟到的预留会被清理。Host 对请求使用独立截止时间和取消信号,限制最多 64 个预留,拒绝重复执行,并在退出时清理状态。取消正在读取的 HTTP 响应会丢弃请求 Future,真实 TCP 测试验证连接关闭。普通桌面请求默认超时 30 秒,可配置至 600 秒。 + +前端清理计时器和监听器,保留二进制传输。写请求派发后取消或超时会报告结果未知,提示先检查业务结果,不自动重试。取消不代表服务端事务回滚;业务提交结果查询仍未完成,不能据此认定完整 A-03 验收通过。协议详见 [Host v1 契约](../contracts/Host-v1契约.md)。 + +## 测试结果 + +| 范围 | 最终结果 | +| --- | --- | +| 后端 pytest | 894 通过 | +| 前端 Vitest | 93 个文件,504 通过 | +| Rust desktop 全目标测试(locked) | 28 通过 | +| Sync pytest | 23 通过 | +| Community pytest | 13 通过 | +| 打包 Core 认证回归 | 4 通过(单独运行,和后端套件有重叠) | +| 三服务隔离冒烟测试 | 通过 | +| 前端类型检查、生产构建 | 通过;构建仍提示部分 chunk 大于 500 kB | +| Rust fmt、Clippy 全目标 `-D warnings` | 通过 | +| 文档链接、变更空白检查 | 通过 | + +本地完整命令、退出码和日志位于 `.build/acceptance/fixes-6bf5127/` 与 `.build/acceptance/fixes-final-6bf5127/`。随文的 JSON 汇总保存命令和最终结果;原始日志属于本地构建产物。首轮新增 TCP 测试因同步等待阻塞 Tokio 清理而失败,改为异步等待线程结束;Clippy 发现 Host 参数数量超限,改为严格的结构化请求 DTO。修复后 Rust、前端及构建检查均重新通过,未改动的 Python 服务沿用本轮先前已通过结果。编辑器测试仅调整冷启动等待预算,保留原有断言。 + +## Sync 测试部署复核 + +对 [Sync 测试服务](http://yui.kronecker.cc:18080) 的公网复核结果为:`/health`、`/ready`、`/sync/v1/handshake` 返回 200,未认证访问 `/sync/v1/vaults` 返回 401。本次修复只涉及桌面端,未重新部署服务。此前 100 MiB 上传下载、并发 CAS、重启持久性等证据仍以首次验收报告为准,本次没有重复这些远程压力测试。 + +## 验收边界 + +本次结论为已知缺陷关闭、现有自动化回归通过。完整生产化验收仍未通过:操作系统级插件隔离、扩展管理、完整 Rust 同步客户端等规划能力仍有未完成项;本次也没有补充 MSVC 安装包、真实双设备桌面操作和完整故障矩阵的验收证据。不能将测试数视为规划中 30 个验收 ID 全部完成。用户 Vault 文件未纳入本次修改或提交。 diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index a3f004e..c8eebaf 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2941,6 +2941,7 @@ dependencies = [ "tauri", "tauri-build", "tempfile", + "tokio", "uuid", "zeroize", ] @@ -5090,9 +5091,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tokio-rustls" version = "0.26.5" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 5e9aaa7..d33b1b1 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ required-features = ["desktop"] [features] default = [] -desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64"] +desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64", "dep:tokio"] [dependencies] serde = { version = "1", features = ["derive"] } @@ -28,6 +28,7 @@ tauri = { version = "2", optional = true, features = ["tray-icon"] } rfd = { version = "0.15", optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } base64 = { version = "0.22", optional = true } +tokio = { version = "1", features = ["rt", "sync", "time", "macros"], optional = true } hmac = { version = "0.12", default-features = false } rand = { version = "0.8", default-features = false, features = ["getrandom"] } zeroize = { version = "1", default-features = false, features = ["alloc"] } diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 9f35d0c..d7b8090 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -22,6 +22,8 @@ fn main() { "credentials_change_password", "credentials_import", "core_request", + "core_request_prepare", + "core_request_cancel", "core_stream", "core_stream_cancel", "editor_capabilities", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index ac5ef36..42189e5 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -14,6 +14,8 @@ "allow-credentials-change-password", "allow-credentials-import", "allow-core-request", + "allow-core-request-prepare", + "allow-core-request-cancel", "allow-core-stream", "allow-core-stream-cancel", "allow-workspace-choose", diff --git a/frontend/src-tauri/permissions/autogenerated/core_request_cancel.toml b/frontend/src-tauri/permissions/autogenerated/core_request_cancel.toml new file mode 100644 index 0000000..179485d --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/core_request_cancel.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-core-request-cancel" +description = "Enables the core_request_cancel command without any pre-configured scope." +commands.allow = ["core_request_cancel"] + +[[permission]] +identifier = "deny-core-request-cancel" +description = "Denies the core_request_cancel command without any pre-configured scope." +commands.deny = ["core_request_cancel"] diff --git a/frontend/src-tauri/permissions/autogenerated/core_request_prepare.toml b/frontend/src-tauri/permissions/autogenerated/core_request_prepare.toml new file mode 100644 index 0000000..03f2c23 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/core_request_prepare.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-core-request-prepare" +description = "Enables the core_request_prepare command without any pre-configured scope." +commands.allow = ["core_request_prepare"] + +[[permission]] +identifier = "deny-core-request-prepare" +description = "Denies the core_request_prepare command without any pre-configured scope." +commands.deny = ["core_request_prepare"] diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index 2d9be30..d8910d0 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -201,6 +201,9 @@ impl Unlocked { pub struct CredentialBroker { path: PathBuf, unlocked: Option, + // Separate stable inode: snapshots are atomically replaced, so locking the + // snapshot itself would not protect the next writer after replacement. + ownership: Option, } impl CredentialBroker { @@ -479,6 +482,7 @@ impl CredentialBroker { Self { path, unlocked: None, + ownership: None, } } pub fn is_locked(&self) -> bool { @@ -486,9 +490,50 @@ impl CredentialBroker { } pub fn lock(&mut self) { self.unlocked.take(); + self.ownership.take(); } pub fn unlock(&mut self, password: Zeroizing>) -> Result<()> { self.lock(); + use fs2::FileExt; + let parent = self.path.parent().ok_or("CREDENTIAL_PATH_INVALID")?; + fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_IO_FAILED")?; + let mut lock_name = self + .path + .file_name() + .ok_or("CREDENTIAL_PATH_INVALID")? + .to_os_string(); + lock_name.push(".lock"); + let lock_path = parent.join(lock_name); + if let Ok(metadata) = fs::symlink_metadata(&lock_path) { + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("CREDENTIAL_PATH_INVALID".into()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err("CREDENTIAL_PATH_INVALID".into()); + } + } + } + let mut options = fs::OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(0x1 | 0x2); // Do not allow replacing the held lock file. + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let ownership = options + .open(&lock_path) + .map_err(|_| "CREDENTIAL_IO_FAILED")?; + ownership + .try_lock_exclusive() + .map_err(|_| "CREDENTIALS_BUSY")?; let session = if self.path.exists() { let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?; if !metadata.is_file() || metadata.len() > MAX_FILE { @@ -530,6 +575,7 @@ impl CredentialBroker { session }; self.unlocked = Some(session); + self.ownership = Some(ownership); Ok(()) } pub fn list(&self) -> Result> { diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 7e04922..135209a 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -3,5 +3,7 @@ pub mod core; pub mod credentials; pub mod recent; +#[cfg(feature = "desktop")] +pub mod request_lifecycle; mod runtime_compat; pub mod workspace; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index ba03154..4414369 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -6,6 +6,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use notesagent_host::core::CoreSupervisor; use notesagent_host::credentials::CredentialBroker; use notesagent_host::recent::{RecentVault, RecentVaultStore}; +use notesagent_host::request_lifecycle::Requests; use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace}; use std::collections::HashMap; use std::path::Path; @@ -16,6 +17,7 @@ use zeroize::Zeroizing; #[derive(Default)] struct Host { + requests: Requests, workspace: Mutex>, recent: Mutex>, core: Arc>>, @@ -87,6 +89,14 @@ fn core_url(path: &str) -> Result { mod core_proxy_tests { use super::{core_url, is_json_content_type}; + #[test] + fn request_dto_accepts_camel_case_and_rejects_unowned_headers() { + let mut payload = serde_json::json!({"requestId":"fixture-reservation","method":"POST","path":"/api/tasks","body":{"title":"fixture"},"contentType":"application/json"}); + assert!(serde_json::from_value::(payload.clone()).is_ok()); + payload["authorization"] = serde_json::json!("must-not-be-forwarded"); + assert!(serde_json::from_value::(payload).is_err()); + } + #[test] fn only_allows_expected_loopback_paths() { assert_eq!(core_url("/health").unwrap(), "http://127.0.0.1:8000/health"); @@ -118,115 +128,155 @@ mod core_proxy_tests { /// Authenticated process-local transport; session headers are owned by Rust. #[tauri::command] -async fn core_request( +fn core_request_prepare(host: State<'_, Host>, timeout_ms: u64) -> Result { + host.requests.prepare(timeout_ms) +} + +#[tauri::command] +fn core_request_cancel(host: State<'_, Host>, request_id: String) -> Result<(), String> { + host.requests.cancel(&request_id) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CoreRequest { + request_id: String, method: String, path: String, body: Option, body_base64: Option, content_type: Option, idempotency_key: Option, - host: State<'_, Host>, -) -> Result { - let core = host.core.clone(); - let core_path = path.clone(); - let session = tauri::async_runtime::spawn_blocking(move || { - core.lock() - .map_err(|_| "HOST_BUSY")? - .as_mut() - .ok_or("CORE_UNAVAILABLE")? - .request_session(&core_path) - }) - .await - .map_err(|_| "CORE_UNAVAILABLE")??; - let method = - reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?; - if !matches!( +} + +#[tauri::command] +async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result { + let CoreRequest { + request_id, method, - reqwest::Method::GET - | reqwest::Method::POST - | reqwest::Method::PUT - | reqwest::Method::PATCH - | reqwest::Method::DELETE - ) { - return Err("CORE_METHOD_DENIED".into()); - } - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() - .map_err(|_| "CORE_CLIENT_ERROR")?; - let mut request = client - .request(method, &session.url) - .header( - reqwest::header::AUTHORIZATION, - session.authorization.as_str(), - ) - .header("X-Core-Generation", &session.generation); - if let Some(value) = body { - request = request.json(&value); - } - if let Some(encoded) = body_base64 { - if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 { - return Err("CORE_REQUEST_TOO_LARGE".into()); - } - let bytes = BASE64_STANDARD - .decode(encoded) - .map_err(|_| "CORE_BODY_INVALID")?; - if bytes.len() > MAX_CORE_RESPONSE_BYTES { - return Err("CORE_REQUEST_TOO_LARGE".into()); - } - let content_type = content_type - .as_deref() - .unwrap_or("application/octet-stream"); - if !matches!(content_type, "application/octet-stream" | "application/zip") { - return Err("CORE_CONTENT_TYPE_DENIED".into()); - } - request = request - .header(reqwest::header::CONTENT_TYPE, content_type) - .body(bytes); - } - if let Some(key) = idempotency_key { - if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { - return Err("CORE_HEADER_INVALID".into()); - } - request = request.header("Idempotency-Key", key); - } - let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; - let status = response.status().as_u16(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_owned(); - if response - .content_length() - .is_some_and(|length| length > MAX_CORE_RESPONSE_BYTES as u64) - { - return Err("CORE_RESPONSE_TOO_LARGE".into()); - } - let mut bytes = Vec::new(); - while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? { - if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES { - return Err("CORE_RESPONSE_TOO_LARGE".into()); - } - bytes.extend_from_slice(&chunk); - } - let (body, body_base64) = if is_json_content_type(&content_type) { - ( - String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?, - None, - ) - } else { - (String::new(), Some(BASE64_STANDARD.encode(&bytes))) - }; - Ok(CoreResponse { - status, - content_type, + path, body, body_base64, - }) + content_type, + idempotency_key, + } = request; + let mut lease = host.requests.claim(&request_id)?; + let checkpoint = lease.checkpoint(); + lease + .run(async { + if body.is_some() && body_base64.is_some() { + return Err("CORE_BODY_INVALID".into()); + } + if body + .as_ref() + .is_some_and(|value| value.to_string().len() > MAX_CORE_RESPONSE_BYTES) + { + return Err("CORE_REQUEST_TOO_LARGE".into()); + } + let core = host.core.clone(); + let core_path = path.clone(); + let session = tauri::async_runtime::spawn_blocking(move || { + core.lock() + .map_err(|_| "HOST_BUSY")? + .as_mut() + .ok_or("CORE_UNAVAILABLE")? + .request_session(&core_path) + }) + .await + .map_err(|_| "CORE_UNAVAILABLE")??; + let method = + reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "CORE_METHOD_DENIED")?; + if !matches!( + method, + reqwest::Method::GET + | reqwest::Method::POST + | reqwest::Method::PUT + | reqwest::Method::PATCH + | reqwest::Method::DELETE + ) { + return Err("CORE_METHOD_DENIED".into()); + } + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(600)) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .map_err(|_| "CORE_CLIENT_ERROR")?; + let mut request = client + .request(method, &session.url) + .header( + reqwest::header::AUTHORIZATION, + session.authorization.as_str(), + ) + .header("X-Core-Generation", &session.generation) + .header("X-Request-Id", &request_id); + if let Some(value) = body { + request = request.json(&value); + } + if let Some(encoded) = body_base64 { + if encoded.len() > MAX_CORE_RESPONSE_BYTES * 4 / 3 + 4 { + return Err("CORE_REQUEST_TOO_LARGE".into()); + } + let bytes = BASE64_STANDARD + .decode(encoded) + .map_err(|_| "CORE_BODY_INVALID")?; + if bytes.len() > MAX_CORE_RESPONSE_BYTES { + return Err("CORE_REQUEST_TOO_LARGE".into()); + } + let content_type = content_type + .as_deref() + .unwrap_or("application/octet-stream"); + if !matches!(content_type, "application/octet-stream" | "application/zip") { + return Err("CORE_CONTENT_TYPE_DENIED".into()); + } + request = request + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(bytes); + } + if let Some(key) = idempotency_key { + if key.len() > 128 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { + return Err("CORE_HEADER_INVALID".into()); + } + request = request.header("Idempotency-Key", key); + } + checkpoint()?; + let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_owned(); + if response + .content_length() + .is_some_and(|length| length > MAX_CORE_RESPONSE_BYTES as u64) + { + return Err("CORE_RESPONSE_TOO_LARGE".into()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| "CORE_RESPONSE_ERROR")? { + if bytes.len().saturating_add(chunk.len()) > MAX_CORE_RESPONSE_BYTES { + return Err("CORE_RESPONSE_TOO_LARGE".into()); + } + bytes.extend_from_slice(&chunk); + } + let (body, body_base64) = if is_json_content_type(&content_type) { + ( + String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?, + None, + ) + } else { + (String::new(), Some(BASE64_STANDARD.encode(&bytes))) + }; + Ok(CoreResponse { + status, + content_type, + body, + body_base64, + }) + }) + .await } #[tauri::command] @@ -627,6 +677,8 @@ fn main() { credentials_change_password, credentials_import, core_request, + core_request_prepare, + core_request_cancel, core_stream, core_stream_cancel, editor_capabilities, diff --git a/frontend/src-tauri/src/request_lifecycle.rs b/frontend/src-tauri/src/request_lifecycle.rs new file mode 100644 index 0000000..ac0e0b4 --- /dev/null +++ b/frontend/src-tauri/src/request_lifecycle.rs @@ -0,0 +1,236 @@ +//! Reserve before dispatch so cancellation cannot race a delayed IPC invocation. +use std::{ + collections::HashMap, + future::Future, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::sync::watch; + +struct Entry { + deadline: Instant, + claimed: bool, + cancel: watch::Sender, +} +#[derive(Default, Clone)] +pub struct Requests { + entries: Arc>>, +} +pub struct Lease { + id: String, + owner: Requests, + deadline: Instant, + cancel: watch::Receiver, +} + +impl Requests { + pub fn prepare(&self, timeout_ms: u64) -> Result { + if !(1..=600_000).contains(&timeout_ms) { + return Err("CORE_TIMEOUT_INVALID".into()); + } + let mut entries = self.entries.lock().map_err(|_| "HOST_BUSY")?; + entries.retain(|_, entry| entry.claimed || entry.deadline > Instant::now()); + if entries.len() >= 64 { + return Err("CORE_REQUEST_LIMIT".into()); + } + let id = uuid::Uuid::new_v4().to_string(); + let (cancel, _) = watch::channel(false); + entries.insert( + id.clone(), + Entry { + deadline: Instant::now() + Duration::from_millis(timeout_ms), + claimed: false, + cancel, + }, + ); + Ok(id) + } + pub fn claim(&self, id: &str) -> Result { + let mut entries = self.entries.lock().map_err(|_| "HOST_BUSY")?; + let entry = entries.get_mut(id).ok_or("CORE_REQUEST_NOT_PREPARED")?; + if entry.claimed { + return Err("CORE_REQUEST_ALREADY_STARTED".into()); + } + if entry.deadline <= Instant::now() { + entries.remove(id); + return Err("REQUEST_TIMEOUT".into()); + } + entry.claimed = true; + Ok(Lease { + id: id.into(), + owner: self.clone(), + deadline: entry.deadline, + cancel: entry.cancel.subscribe(), + }) + } + pub fn cancel(&self, id: &str) -> Result<(), String> { + if let Some(entry) = self.entries.lock().map_err(|_| "HOST_BUSY")?.remove(id) { + entry.cancel.send_replace(true); + } + Ok(()) + } +} +impl Lease { + /// Recheck after synchronous encoding/validation, immediately before network IO. + pub fn checkpoint(&self) -> impl Fn() -> Result<(), String> + Send + 'static { + let cancel = self.cancel.clone(); + let deadline = self.deadline; + move || { + if *cancel.borrow() { + return Err("REQUEST_CANCELLED".into()); + } + if deadline <= Instant::now() { + return Err("REQUEST_TIMEOUT".into()); + } + Ok(()) + } + } + pub async fn run( + &mut self, + operation: impl Future>, + ) -> Result { + // Check current state before polling an operation with possible side effects. + if *self.cancel.borrow() { + return Err("REQUEST_CANCELLED".into()); + } + if self.deadline <= Instant::now() { + return Err("REQUEST_TIMEOUT".into()); + } + tokio::select! { + biased; + _ = self.cancel.changed() => Err("REQUEST_CANCELLED".into()), + _ = tokio::time::sleep_until(self.deadline.into()) => Err("REQUEST_TIMEOUT".into()), + value = operation => value, + } + } +} +impl Drop for Lease { + fn drop(&mut self) { + if let Ok(mut entries) = self.owner.entries.lock() { + entries.remove(&self.id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn cancellation_before_dispatch_and_replay_never_run_work() { + let requests = Requests::default(); + let id = requests.prepare(1000).unwrap(); + requests.cancel(&id).unwrap(); + assert!(requests.claim(&id).is_err()); + let id = requests.prepare(1000).unwrap(); + let mut lease = requests.claim(&id).unwrap(); + assert!(requests.claim(&id).is_err()); + requests.cancel(&id).unwrap(); + let result: Result<(), String> = lease + .run(async { panic!("cancelled work was polled") }) + .await; + assert_eq!(result.unwrap_err(), "REQUEST_CANCELLED"); + } + #[tokio::test] + async fn timeout_and_cancel_drop_inflight_work_and_release_capacity() { + let requests = Requests::default(); + let id = requests.prepare(10).unwrap(); + let mut lease = requests.claim(&id).unwrap(); + assert_eq!( + lease + .run(std::future::pending::>()) + .await + .unwrap_err(), + "REQUEST_TIMEOUT" + ); + drop(lease); + assert!(requests.entries.lock().unwrap().is_empty()); + let id = requests.prepare(1000).unwrap(); + let mut lease = requests.claim(&id).unwrap(); + let (result, ()) = tokio::join!( + lease.run(std::future::pending::>()), + async { + tokio::task::yield_now().await; + requests.cancel(&id).unwrap(); + } + ); + assert_eq!(result.unwrap_err(), "REQUEST_CANCELLED"); + drop(lease); + assert!(requests.entries.lock().unwrap().is_empty()); + } + #[test] + fn capacity_and_expired_reservations_are_bounded() { + let requests = Requests::default(); + for _ in 0..64 { + requests.prepare(1000).unwrap(); + } + assert_eq!(requests.prepare(1000).unwrap_err(), "CORE_REQUEST_LIMIT"); + for entry in requests.entries.lock().unwrap().values_mut() { + entry.deadline = Instant::now(); + } + assert!(requests.prepare(1000).is_ok()); + } + #[tokio::test] + async fn cancelling_a_real_response_closes_its_socket() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let (started, received) = tokio::sync::oneshot::channel(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + socket.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\nx", + ) + .unwrap(); + started.send(()).unwrap(); + match socket.read(&mut [0]) { + Ok(0) => true, + Err(error) => matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted + ), + _ => false, + } + }); + let requests = Requests::default(); + let id = requests.prepare(10000).unwrap(); + let mut lease = requests.claim(&id).unwrap(); + let operation = async { + let mut response = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(format!("http://{endpoint}/")) + .send() + .await + .map_err(|_| "HTTP_FAILURE".to_string())?; + while response + .chunk() + .await + .map_err(|_| "BODY_FAILURE".to_string())? + .is_some() + {} + Ok(()) + }; + let (result, ()) = tokio::join!(lease.run(operation), async { + received.await.unwrap(); + requests.cancel(&id).unwrap(); + }); + assert_eq!(result.unwrap_err(), "REQUEST_CANCELLED"); + assert!( + tokio::task::spawn_blocking(move || server.join().unwrap()) + .await + .unwrap(), + "HTTP socket was not closed on cancellation" + ); + } +} diff --git a/frontend/src-tauri/tests/credential_ownership.rs b/frontend/src-tauri/tests/credential_ownership.rs new file mode 100644 index 0000000..1836591 --- /dev/null +++ b/frontend/src-tauri/tests/credential_ownership.rs @@ -0,0 +1,114 @@ +use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope}; +use zeroize::Zeroizing; +fn password() -> Zeroizing> { + Zeroizing::new(b"isolated-ownership-fixture".to_vec()) +} +#[test] +fn stale_writer_is_rejected_then_handoff_preserves_both_commits() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("vault"); + let a = CredentialId::legacy("first"); + let b = CredentialId::legacy("second"); + let mut one = CredentialBroker::new(path.clone()); + one.unlock(password()).unwrap(); + let mut two = CredentialBroker::new(path.clone()); + assert_eq!(two.unlock(password()).unwrap_err(), "CREDENTIALS_BUSY"); + assert!(two.is_locked()); + one.put(&a, Zeroizing::new(b"fixture-a".to_vec())).unwrap(); + assert_eq!( + two.put(&b, Zeroizing::new(b"fixture-b".to_vec())) + .unwrap_err(), + "CREDENTIALS_LOCKED" + ); + one.lock(); + two.unlock(password()).unwrap(); + assert!(two.resolve(&Scope::Provider, &a).unwrap().is_some()); + two.put(&b, Zeroizing::new(b"fixture-b".to_vec())).unwrap(); + two.lock(); + // A failed password attempt must release its ownership too. + assert!(one + .unlock(Zeroizing::new(b"wrong-fixture-password".to_vec())) + .is_err()); + two.unlock(password()).unwrap(); + assert!(two.resolve(&Scope::Provider, &a).unwrap().is_some()); + assert!(two.resolve(&Scope::Provider, &b).unwrap().is_some()); +} + +#[test] +fn process_lock_is_exclusive_and_released_on_crash() { + use std::io::{BufRead, Read, Write}; + use std::process::{Command, Stdio}; + if let Ok(role) = std::env::var("OPENNEXUS_OWNERSHIP_TEST_ROLE") { + let path = + std::path::PathBuf::from(std::env::var_os("OPENNEXUS_OWNERSHIP_TEST_PATH").unwrap()); + let mut broker = CredentialBroker::new(path); + if role == "blocked" { + assert_eq!(broker.unlock(password()).unwrap_err(), "CREDENTIALS_BUSY"); + return; + } + broker.unlock(password()).unwrap(); + broker + .put( + &CredentialId::legacy("child"), + Zeroizing::new(b"child-fixture".to_vec()), + ) + .unwrap(); + println!("OWNERSHIP_READY"); + std::io::stdout().flush().unwrap(); + let _ = std::io::stdin().read_exact(&mut [0]); + return; + } + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("vault"); + let mut broker = CredentialBroker::new(path.clone()); + broker.unlock(password()).unwrap(); + let command = |role: &str| { + let mut c = Command::new(std::env::current_exe().unwrap()); + c.args([ + "--exact", + "process_lock_is_exclusive_and_released_on_crash", + "--nocapture", + ]) + .env("OPENNEXUS_OWNERSHIP_TEST_ROLE", role) + .env("OPENNEXUS_OWNERSHIP_TEST_PATH", &path); + c + }; + assert!(command("blocked") + .stdout(Stdio::null()) + .status() + .unwrap() + .success()); + broker.lock(); + let mut child = command("owner") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let output = child.stdout.take().unwrap(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for line in std::io::BufReader::new(output) + .lines() + .map_while(Result::ok) + { + if line == "OWNERSHIP_READY" { + let _ = sender.send(()); + break; + } + } + }); + let ready = receiver + .recv_timeout(std::time::Duration::from_secs(30)) + .is_ok(); + if ready { + assert_eq!(broker.unlock(password()).unwrap_err(), "CREDENTIALS_BUSY"); + } + child.kill().unwrap(); + child.wait().unwrap(); + assert!(ready, "child never acquired ownership"); + broker.unlock(password()).unwrap(); + assert!(broker + .resolve(&Scope::Provider, &CredentialId::legacy("child")) + .unwrap() + .is_some()); +} diff --git a/frontend/src/features/editor/EditorPane.spec.ts b/frontend/src/features/editor/EditorPane.spec.ts index 7e279c7..1da30b2 100644 --- a/frontend/src/features/editor/EditorPane.spec.ts +++ b/frontend/src/features/editor/EditorPane.spec.ts @@ -63,10 +63,10 @@ describe('EditorPane file switching', () => { wrapper = mount(EditorPane, { attachTo: document.body }) await nextTick() - await vi.waitFor(() => expect(wrapper!.find('.cm-content').exists()).toBe(true)) + await vi.waitFor(() => expect(wrapper!.find('.cm-content').exists()).toBe(true), { timeout: 10000 }) const textarea = wrapper.get('.cm-content') expect(textarea.attributes('spellcheck')).toBe('true') expect(textarea.attributes('lang')).toBe('en') expect(textarea.attributes('aria-label')).toBe('Markdown source editor') - }) + }, 15000) // Lazy source-editor module transforms need the same cold-start budget. }) diff --git a/frontend/src/features/settings/CredentialVaultSettings.vue b/frontend/src/features/settings/CredentialVaultSettings.vue index 18f9d05..a30098a 100644 --- a/frontend/src/features/settings/CredentialVaultSettings.vue +++ b/frontend/src/features/settings/CredentialVaultSettings.vue @@ -8,6 +8,12 @@ const busy = ref(false) const password = ref('') const confirmation = ref('') const message = ref('') +function failureMessage(error: unknown, fallback: string) { + const code = error instanceof Error ? error.message : fallback + return code === 'CREDENTIALS_BUSY' + ? t('保险库正在使用中,请等待当前操作完成,或在其他 OpenNexus 实例中锁定后重试。', 'The vault is busy. Wait for the current operation, or lock it in the other OpenNexus instance before retrying.') + : code +} async function refresh() { const state = await hostInvoke<{ locked: boolean }>('credentials_status') locked.value = state.locked @@ -17,7 +23,7 @@ async function importLegacy() { try { const count = await hostInvoke('credentials_import') if (count !== null) message.value = t(`已迁移并验证 ${count} 条凭据;旧文件仍保留。`, `Imported and verified ${count} credentials. Legacy files are retained.`) - } catch (error) { message.value = error instanceof Error ? error.message : 'MIGRATION_FAILED' } + } catch (error) { message.value = failureMessage(error, 'MIGRATION_FAILED') } finally { busy.value = false } } async function act(action: 'unlock' | 'lock' | 'change_password') { @@ -33,7 +39,7 @@ async function act(action: 'unlock' | 'lock' | 'change_password') { await hostInvoke(`credentials_${action}`, action === 'lock' ? undefined : { password: value }) await refresh() message.value = action === 'change_password' ? t('口令已更新。', 'Password updated.') : '' - } catch (error) { message.value = error instanceof Error ? error.message : 'CREDENTIAL_STORE_FAILED' } + } catch (error) { message.value = failureMessage(error, 'CREDENTIAL_STORE_FAILED') } finally { busy.value = false } } onMounted(() => refresh().catch(error => { message.value = String(error) })) diff --git a/frontend/src/services/apiClient.cancellation.spec.ts b/frontend/src/services/apiClient.cancellation.spec.ts new file mode 100644 index 0000000..6827f7b --- /dev/null +++ b/frontend/src/services/apiClient.cancellation.spec.ts @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom +import { expect, it, vi, beforeEach, afterEach } from 'vitest' +const { hostInvoke } = vi.hoisted(() => ({ hostInvoke: vi.fn() })) +vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke })) +import apiClient from './apiClient' +beforeEach(() => { + hostInvoke.mockReset() + hostInvoke.mockImplementation(command => command === 'core_request_prepare' ? Promise.resolve('reservation') : + command === 'core_request_cancel' ? Promise.resolve() : new Promise(() => {})) +}) +afterEach(() => vi.useRealTimers()) +it('never invokes Host for a pre-aborted mutation', async () => { + const abort = new AbortController(); abort.abort() + await expect(apiClient.post('/api/tasks', {}, { signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_CANCELLED', details: { outcome: 'not_sent' } }) + expect(hostInvoke).not.toHaveBeenCalled() +}) +it('rejects on deadline and cancels the native work rather than awaiting its response', async () => { + vi.useFakeTimers() + const result = expect(apiClient.post('/api/tasks', {}, { timeoutMs: 10 })).rejects.toMatchObject({ code: 'REQUEST_TIMEOUT', details: { outcome: 'unknown' } }) + await vi.advanceTimersByTimeAsync(10); await result + expect(hostInvoke).toHaveBeenCalledWith('core_request_cancel', { requestId: 'reservation' }) + expect(vi.getTimerCount()).toBe(0) +}) +it('cancels a late reservation without dispatching after the caller has aborted', async () => { + let reserve!: (id: string) => void + hostInvoke.mockImplementationOnce(() => new Promise(resolve => { reserve = resolve })) + const abort = new AbortController() + const result = expect(apiClient.post('/api/tasks', {}, { signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_CANCELLED' }) + abort.abort(); await result; reserve('late-reservation') + await vi.waitFor(() => expect(hostInvoke).toHaveBeenCalledWith('core_request_cancel', { requestId: 'late-reservation' })) + expect(hostInvoke.mock.calls.some(([command]) => command === 'core_request')).toBe(false) +}) +it('propagates native deadline errors even when the browser timer has not fired', async () => { + hostInvoke.mockImplementation(command => command === 'core_request_prepare' ? Promise.resolve('reservation') : command === 'core_request' ? Promise.reject({code:'REQUEST_TIMEOUT'}) : Promise.resolve()) + await expect(apiClient.get('/api/status')).rejects.toMatchObject({code:'REQUEST_TIMEOUT'}) +}) +it('dispatches the frozen request envelope and clears its deadline after success', async () => { + vi.useFakeTimers() + hostInvoke.mockImplementation(command => Promise.resolve(command === 'core_request_prepare' ? 'reservation' : {status:200,content_type:'application/json',body:'{"ok":true}'})) + expect(await apiClient.post('/api/tasks', {title:'fixture'}, {token:'not-forwarded'})).toEqual({ok:true}) + expect(hostInvoke).toHaveBeenCalledWith('core_request', {request: expect.objectContaining({requestId:'reservation',method:'POST',path:'/api/tasks',body:{title:'fixture'}})}) + expect(JSON.stringify(hostInvoke.mock.calls)).not.toContain('not-forwarded') + expect(vi.getTimerCount()).toBe(0) +}) +it('retains binary bytes and media type through the cancellable transport', async () => { + hostInvoke.mockImplementation(command => Promise.resolve(command === 'core_request_prepare' ? 'reservation' : {status:200,content_type:'application/json',body:'{}'})) + await apiClient.postBinary('/api/packages',new Blob([new Uint8Array([0,255,128])])) + expect(hostInvoke).toHaveBeenCalledWith('core_request', {request: expect.objectContaining({requestId:'reservation',bodyBase64:'AP+A',contentType:'application/zip'})}) +}) diff --git a/frontend/src/services/apiClient.desktop.spec.ts b/frontend/src/services/apiClient.desktop.spec.ts index 36ec5cb..91a38d9 100644 --- a/frontend/src/services/apiClient.desktop.spec.ts +++ b/frontend/src/services/apiClient.desktop.spec.ts @@ -6,7 +6,10 @@ vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke })) import apiClient from './apiClient' -beforeEach(() => hostInvoke.mockReset()) +beforeEach(() => { + hostInvoke.mockReset() + hostInvoke.mockResolvedValueOnce('test-reservation') +}) it('restores binary desktop responses as browser-compatible response objects', async () => { hostInvoke.mockResolvedValue({ diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index b1b2423..9c35911 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -1,5 +1,6 @@ import type { ApiError, ErrorResponse } from '@/contracts' -import { hostInvoke, isDesktop } from './platform/desktop' +import { isDesktop } from './platform/desktop' +import { coreRequest, type RequestProgress } from './platform/coreRequest' // 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。 const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? '' @@ -50,12 +51,18 @@ export class ApiErrorClass extends Error { async function request(path: string, options: RequestOptions = {}): Promise { const { params, token, headers, timeoutMs, ...rest } = options - const controller = timeoutMs ? new AbortController() : null + const desktop = isDesktop() + const deadlineMs = timeoutMs ?? (desktop ? 30_000 : undefined) + if (deadlineMs !== undefined && (!Number.isFinite(deadlineMs) || deadlineMs <= 0 || (desktop && deadlineMs > 600_000))) { + throw new ApiErrorClass('CORE_TIMEOUT_INVALID', '请求超时设置无效') + } + const controller = new AbortController() + const progress: RequestProgress = { issued: false } let timedOut = false - const abort = () => controller?.abort() + const abort = () => controller.abort() if (rest.signal?.aborted) abort() rest.signal?.addEventListener('abort', abort, { once: true }) - const timer = timeoutMs ? setTimeout(() => { timedOut = true; controller?.abort() }, timeoutMs) : undefined + const timer = deadlineMs ? setTimeout(() => { timedOut = true; controller.abort() }, deadlineMs) : undefined let url = resolveApiUrl(path) @@ -81,7 +88,8 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise('core_request', { + const response = await coreRequest({ method: rest.method ?? 'GET', path: `${parsed.pathname}${parsed.search}`, body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined, bodyBase64, contentType: new Headers(reqHeaders).get('Content-Type'), idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined, - }) + }, controller.signal, Math.ceil(deadlineMs!), progress) if (response.status >= 200 && response.status < 300) { if (response.status === 204) return undefined as T return (response.content_type.includes('application/json') @@ -111,7 +119,7 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise(args: Record, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise { + return new Promise((resolve, reject) => { + let settled = false + let requestId: string | undefined + const cancel = () => { + if (requestId) void hostInvoke('core_request_cancel', { requestId }).catch(() => {}) + } + const abort = () => { + if (settled) return + settled = true + signal.removeEventListener('abort', abort) + cancel() + reject(new DOMException('Request aborted', 'AbortError')) + } + if (signal.aborted) { abort(); return } + signal.addEventListener('abort', abort, { once: true }) + void (async () => { + try { + requestId = await hostInvoke('core_request_prepare', { timeoutMs }) + progress.requestId = requestId + if (settled || signal.aborted) { cancel(); return } + progress.issued = true + const result = await hostInvoke('core_request', { request: { ...args, requestId } }) + if (!settled) { settled = true; resolve(result) } + } catch (error) { + if (!settled) { settled = true; reject(error) } + } finally { + signal.removeEventListener('abort', abort) + // Also discard a reservation if dispatch failed before Rust claimed it. + cancel() + } + })() + }) +}