feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# OpenNexus 文档索引
|
# 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 基线。
|
> 第二阶段收尾:标准 Agent/RAG Benchmark 与报告页、函数图预览、三格式快照导出及真实 Provider/MCP 结果见[实现与验收记录](development/第二阶段收尾实现与验收-2026-09-07.md)。当前分支尚未合并,不更改下文历史 main 基线。
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
| 命令 | 参数 / 返回 |
|
| 命令 | 参数 / 返回 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `host_capabilities` | protocol=1,workspace/credentials=true;core 随进程状态返回;sync/extensions=false |
|
| `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_choose` | 原生选择,取消返回 null;成功返回 vault_id/path/name |
|
||||||
| `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 |
|
| `workspace_open` | 只允许重开应用数据目录中已持久化授权的规范化路径 |
|
||||||
| `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 |
|
| `workspace_recent` | 最近 20 个用户主动选择的 Vault;只保存身份、名称和路径 |
|
||||||
@@ -21,7 +23,9 @@
|
|||||||
| `workspace_delete` | path、expected;正文保存在 `.ainote/trash` |
|
| `workspace_delete` | path、expected;正文保存在 `.ainote/trash` |
|
||||||
| `workspace_mkdir` | path,相对当前授权 Vault |
|
| `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 禁止在持锁期间替换该锁文件。
|
||||||
|
|
||||||
## 写入及恢复
|
## 写入及恢复
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 文件未纳入本次修改或提交。
|
||||||
Generated
+13
@@ -2941,6 +2941,7 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -5090,9 +5091,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.5"
|
version = "0.26.5"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ required-features = ["desktop"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
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]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -28,6 +28,7 @@ tauri = { version = "2", optional = true, features = ["tray-icon"] }
|
|||||||
rfd = { version = "0.15", optional = true }
|
rfd = { version = "0.15", optional = true }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||||
base64 = { version = "0.22", 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 }
|
hmac = { version = "0.12", default-features = false }
|
||||||
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
|
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
|
||||||
zeroize = { version = "1", default-features = false, features = ["alloc"] }
|
zeroize = { version = "1", default-features = false, features = ["alloc"] }
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ fn main() {
|
|||||||
"credentials_change_password",
|
"credentials_change_password",
|
||||||
"credentials_import",
|
"credentials_import",
|
||||||
"core_request",
|
"core_request",
|
||||||
|
"core_request_prepare",
|
||||||
|
"core_request_cancel",
|
||||||
"core_stream",
|
"core_stream",
|
||||||
"core_stream_cancel",
|
"core_stream_cancel",
|
||||||
"editor_capabilities",
|
"editor_capabilities",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"allow-credentials-change-password",
|
"allow-credentials-change-password",
|
||||||
"allow-credentials-import",
|
"allow-credentials-import",
|
||||||
"allow-core-request",
|
"allow-core-request",
|
||||||
|
"allow-core-request-prepare",
|
||||||
|
"allow-core-request-cancel",
|
||||||
"allow-core-stream",
|
"allow-core-stream",
|
||||||
"allow-core-stream-cancel",
|
"allow-core-stream-cancel",
|
||||||
"allow-workspace-choose",
|
"allow-workspace-choose",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -201,6 +201,9 @@ impl Unlocked {
|
|||||||
pub struct CredentialBroker {
|
pub struct CredentialBroker {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
unlocked: Option<Unlocked>,
|
unlocked: Option<Unlocked>,
|
||||||
|
// Separate stable inode: snapshots are atomically replaced, so locking the
|
||||||
|
// snapshot itself would not protect the next writer after replacement.
|
||||||
|
ownership: Option<fs::File>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CredentialBroker {
|
impl CredentialBroker {
|
||||||
@@ -479,6 +482,7 @@ impl CredentialBroker {
|
|||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
unlocked: None,
|
unlocked: None,
|
||||||
|
ownership: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn is_locked(&self) -> bool {
|
pub fn is_locked(&self) -> bool {
|
||||||
@@ -486,9 +490,50 @@ impl CredentialBroker {
|
|||||||
}
|
}
|
||||||
pub fn lock(&mut self) {
|
pub fn lock(&mut self) {
|
||||||
self.unlocked.take();
|
self.unlocked.take();
|
||||||
|
self.ownership.take();
|
||||||
}
|
}
|
||||||
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
pub fn unlock(&mut self, password: Zeroizing<Vec<u8>>) -> Result<()> {
|
||||||
self.lock();
|
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 session = if self.path.exists() {
|
||||||
let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
let metadata = fs::symlink_metadata(&self.path).map_err(|_| "CREDENTIAL_IO_FAILED")?;
|
||||||
if !metadata.is_file() || metadata.len() > MAX_FILE {
|
if !metadata.is_file() || metadata.len() > MAX_FILE {
|
||||||
@@ -530,6 +575,7 @@ impl CredentialBroker {
|
|||||||
session
|
session
|
||||||
};
|
};
|
||||||
self.unlocked = Some(session);
|
self.unlocked = Some(session);
|
||||||
|
self.ownership = Some(ownership);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn list(&self) -> Result<Vec<CredentialId>> {
|
pub fn list(&self) -> Result<Vec<CredentialId>> {
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
pub mod recent;
|
pub mod recent;
|
||||||
|
#[cfg(feature = "desktop")]
|
||||||
|
pub mod request_lifecycle;
|
||||||
mod runtime_compat;
|
mod runtime_compat;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
|||||||
use notesagent_host::core::CoreSupervisor;
|
use notesagent_host::core::CoreSupervisor;
|
||||||
use notesagent_host::credentials::CredentialBroker;
|
use notesagent_host::credentials::CredentialBroker;
|
||||||
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
||||||
|
use notesagent_host::request_lifecycle::Requests;
|
||||||
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -16,6 +17,7 @@ use zeroize::Zeroizing;
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Host {
|
struct Host {
|
||||||
|
requests: Requests,
|
||||||
workspace: Mutex<Option<Workspace>>,
|
workspace: Mutex<Option<Workspace>>,
|
||||||
recent: Mutex<Option<RecentVaultStore>>,
|
recent: Mutex<Option<RecentVaultStore>>,
|
||||||
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
||||||
@@ -87,6 +89,14 @@ fn core_url(path: &str) -> Result<String, String> {
|
|||||||
mod core_proxy_tests {
|
mod core_proxy_tests {
|
||||||
use super::{core_url, is_json_content_type};
|
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::<super::CoreRequest>(payload.clone()).is_ok());
|
||||||
|
payload["authorization"] = serde_json::json!("must-not-be-forwarded");
|
||||||
|
assert!(serde_json::from_value::<super::CoreRequest>(payload).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_allows_expected_loopback_paths() {
|
fn only_allows_expected_loopback_paths() {
|
||||||
assert_eq!(core_url("/health").unwrap(), "http://127.0.0.1:8000/health");
|
assert_eq!(core_url("/health").unwrap(), "http://127.0.0.1:8000/health");
|
||||||
@@ -118,15 +128,51 @@ mod core_proxy_tests {
|
|||||||
|
|
||||||
/// Authenticated process-local transport; session headers are owned by Rust.
|
/// Authenticated process-local transport; session headers are owned by Rust.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn core_request(
|
fn core_request_prepare(host: State<'_, Host>, timeout_ms: u64) -> Result<String, String> {
|
||||||
|
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,
|
method: String,
|
||||||
path: String,
|
path: String,
|
||||||
body: Option<serde_json::Value>,
|
body: Option<serde_json::Value>,
|
||||||
body_base64: Option<String>,
|
body_base64: Option<String>,
|
||||||
content_type: Option<String>,
|
content_type: Option<String>,
|
||||||
idempotency_key: Option<String>,
|
idempotency_key: Option<String>,
|
||||||
host: State<'_, Host>,
|
}
|
||||||
) -> Result<CoreResponse, String> {
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn core_request(request: CoreRequest, host: State<'_, Host>) -> Result<CoreResponse, String> {
|
||||||
|
let CoreRequest {
|
||||||
|
request_id,
|
||||||
|
method,
|
||||||
|
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 = host.core.clone();
|
||||||
let core_path = path.clone();
|
let core_path = path.clone();
|
||||||
let session = tauri::async_runtime::spawn_blocking(move || {
|
let session = tauri::async_runtime::spawn_blocking(move || {
|
||||||
@@ -151,7 +197,7 @@ async fn core_request(
|
|||||||
return Err("CORE_METHOD_DENIED".into());
|
return Err("CORE_METHOD_DENIED".into());
|
||||||
}
|
}
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(600))
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.build()
|
.build()
|
||||||
@@ -162,7 +208,8 @@ async fn core_request(
|
|||||||
reqwest::header::AUTHORIZATION,
|
reqwest::header::AUTHORIZATION,
|
||||||
session.authorization.as_str(),
|
session.authorization.as_str(),
|
||||||
)
|
)
|
||||||
.header("X-Core-Generation", &session.generation);
|
.header("X-Core-Generation", &session.generation)
|
||||||
|
.header("X-Request-Id", &request_id);
|
||||||
if let Some(value) = body {
|
if let Some(value) = body {
|
||||||
request = request.json(&value);
|
request = request.json(&value);
|
||||||
}
|
}
|
||||||
@@ -192,6 +239,7 @@ async fn core_request(
|
|||||||
}
|
}
|
||||||
request = request.header("Idempotency-Key", key);
|
request = request.header("Idempotency-Key", key);
|
||||||
}
|
}
|
||||||
|
checkpoint()?;
|
||||||
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
let mut response = request.send().await.map_err(|_| "CORE_UNAVAILABLE")?;
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let content_type = response
|
let content_type = response
|
||||||
@@ -227,6 +275,8 @@ async fn core_request(
|
|||||||
body,
|
body,
|
||||||
body_base64,
|
body_base64,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -627,6 +677,8 @@ fn main() {
|
|||||||
credentials_change_password,
|
credentials_change_password,
|
||||||
credentials_import,
|
credentials_import,
|
||||||
core_request,
|
core_request,
|
||||||
|
core_request_prepare,
|
||||||
|
core_request_cancel,
|
||||||
core_stream,
|
core_stream,
|
||||||
core_stream_cancel,
|
core_stream_cancel,
|
||||||
editor_capabilities,
|
editor_capabilities,
|
||||||
|
|||||||
@@ -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<bool>,
|
||||||
|
}
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct Requests {
|
||||||
|
entries: Arc<Mutex<HashMap<String, Entry>>>,
|
||||||
|
}
|
||||||
|
pub struct Lease {
|
||||||
|
id: String,
|
||||||
|
owner: Requests,
|
||||||
|
deadline: Instant,
|
||||||
|
cancel: watch::Receiver<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Requests {
|
||||||
|
pub fn prepare(&self, timeout_ms: u64) -> Result<String, String> {
|
||||||
|
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<Lease, String> {
|
||||||
|
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<T>(
|
||||||
|
&mut self,
|
||||||
|
operation: impl Future<Output = Result<T, String>>,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
// 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::<Result<(), String>>())
|
||||||
|
.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::<Result<(), String>>()),
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
use notesagent_host::credentials::{CredentialBroker, CredentialId, Scope};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
fn password() -> Zeroizing<Vec<u8>> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -63,10 +63,10 @@ describe('EditorPane file switching', () => {
|
|||||||
wrapper = mount(EditorPane, { attachTo: document.body })
|
wrapper = mount(EditorPane, { attachTo: document.body })
|
||||||
await nextTick()
|
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')
|
const textarea = wrapper.get('.cm-content')
|
||||||
expect(textarea.attributes('spellcheck')).toBe('true')
|
expect(textarea.attributes('spellcheck')).toBe('true')
|
||||||
expect(textarea.attributes('lang')).toBe('en')
|
expect(textarea.attributes('lang')).toBe('en')
|
||||||
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
|
expect(textarea.attributes('aria-label')).toBe('Markdown source editor')
|
||||||
})
|
}, 15000) // Lazy source-editor module transforms need the same cold-start budget.
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const busy = ref(false)
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirmation = ref('')
|
const confirmation = ref('')
|
||||||
const message = 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() {
|
async function refresh() {
|
||||||
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
|
const state = await hostInvoke<{ locked: boolean }>('credentials_status')
|
||||||
locked.value = state.locked
|
locked.value = state.locked
|
||||||
@@ -17,7 +23,7 @@ async function importLegacy() {
|
|||||||
try {
|
try {
|
||||||
const count = await hostInvoke<number | null>('credentials_import')
|
const count = await hostInvoke<number | null>('credentials_import')
|
||||||
if (count !== null) message.value = t(`已迁移并验证 ${count} 条凭据;旧文件仍保留。`, `Imported and verified ${count} credentials. Legacy files are retained.`)
|
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 }
|
finally { busy.value = false }
|
||||||
}
|
}
|
||||||
async function act(action: 'unlock' | 'lock' | 'change_password') {
|
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 hostInvoke(`credentials_${action}`, action === 'lock' ? undefined : { password: value })
|
||||||
await refresh()
|
await refresh()
|
||||||
message.value = action === 'change_password' ? t('口令已更新。', 'Password updated.') : ''
|
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 }
|
finally { busy.value = false }
|
||||||
}
|
}
|
||||||
onMounted(() => refresh().catch(error => { message.value = String(error) }))
|
onMounted(() => refresh().catch(error => { message.value = String(error) }))
|
||||||
|
|||||||
@@ -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'})})
|
||||||
|
})
|
||||||
@@ -6,7 +6,10 @@ vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke }))
|
|||||||
|
|
||||||
import apiClient from './apiClient'
|
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 () => {
|
it('restores binary desktop responses as browser-compatible response objects', async () => {
|
||||||
hostInvoke.mockResolvedValue({
|
hostInvoke.mockResolvedValue({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ApiError, ErrorResponse } from '@/contracts'
|
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 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
|
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { params, token, headers, timeoutMs, ...rest } = options
|
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
|
let timedOut = false
|
||||||
const abort = () => controller?.abort()
|
const abort = () => controller.abort()
|
||||||
if (rest.signal?.aborted) abort()
|
if (rest.signal?.aborted) abort()
|
||||||
rest.signal?.addEventListener('abort', abort, { once: true })
|
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)
|
let url = resolveApiUrl(path)
|
||||||
|
|
||||||
@@ -81,7 +88,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
reqHeaders['X-Request-Id'] = reqId
|
reqHeaders['X-Request-Id'] = reqId
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isDesktop()) {
|
controller.signal.throwIfAborted()
|
||||||
|
if (desktop) {
|
||||||
const parsed = new URL(url, 'http://localhost')
|
const parsed = new URL(url, 'http://localhost')
|
||||||
let bodyBase64: string | undefined
|
let bodyBase64: string | undefined
|
||||||
if (rest.body instanceof Blob) {
|
if (rest.body instanceof Blob) {
|
||||||
@@ -91,14 +99,14 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
|
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
|
||||||
bodyBase64 = btoa(parts.join(''))
|
bodyBase64 = btoa(parts.join(''))
|
||||||
}
|
}
|
||||||
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
|
const response = await coreRequest<DesktopCoreResponse>({
|
||||||
method: rest.method ?? 'GET',
|
method: rest.method ?? 'GET',
|
||||||
path: `${parsed.pathname}${parsed.search}`,
|
path: `${parsed.pathname}${parsed.search}`,
|
||||||
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
|
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
|
||||||
bodyBase64,
|
bodyBase64,
|
||||||
contentType: new Headers(reqHeaders).get('Content-Type'),
|
contentType: new Headers(reqHeaders).get('Content-Type'),
|
||||||
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
|
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
|
||||||
})
|
}, controller.signal, Math.ceil(deadlineMs!), progress)
|
||||||
if (response.status >= 200 && response.status < 300) {
|
if (response.status >= 200 && response.status < 300) {
|
||||||
if (response.status === 204) return undefined as T
|
if (response.status === 204) return undefined as T
|
||||||
return (response.content_type.includes('application/json')
|
return (response.content_type.includes('application/json')
|
||||||
@@ -111,7 +119,7 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
}
|
}
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
...rest,
|
...rest,
|
||||||
signal: controller?.signal ?? rest.signal,
|
signal: controller.signal,
|
||||||
headers: reqHeaders,
|
headers: reqHeaders,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,7 +144,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
|
|
||||||
throw new ApiErrorClass(code, message, details)
|
throw new ApiErrorClass(code, message, details)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (timedOut) throw new ApiErrorClass('REQUEST_TIMEOUT', '请求超时,请检查后端状态后重试。')
|
const nativeCode = (e as { code?: string })?.code
|
||||||
|
const uncertain = desktop && progress.issued && !['GET', 'HEAD'].includes(rest.method ?? 'GET')
|
||||||
|
const details = desktop ? { request_id: progress.requestId, outcome: progress.issued ? 'unknown' : 'not_sent' } : undefined
|
||||||
|
if (timedOut || nativeCode === 'REQUEST_TIMEOUT') throw new ApiErrorClass('REQUEST_TIMEOUT', uncertain ? '请求超时,变更可能已提交,请先检查结果。' : '请求超时,请检查连接。', details)
|
||||||
|
if (controller.signal.aborted || nativeCode === 'REQUEST_CANCELLED') throw new ApiErrorClass('REQUEST_CANCELLED', uncertain ? '请求已取消,变更可能已提交,请先检查结果。' : '请求已取消。', details)
|
||||||
if (e instanceof ApiErrorClass) throw e
|
if (e instanceof ApiErrorClass) throw e
|
||||||
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
|
throw new ApiErrorClass('NETWORK_ERROR', (e as Error).message || 'Network error')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { hostInvoke } from './desktop'
|
||||||
|
|
||||||
|
export interface RequestProgress { issued: boolean; requestId?: string }
|
||||||
|
|
||||||
|
/** A reservation makes cancel-before-dispatch definitive even across IPC ordering. */
|
||||||
|
export function coreRequest<T>(args: Record<string, unknown>, signal: AbortSignal, timeoutMs: number, progress: RequestProgress): Promise<T> {
|
||||||
|
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<string>('core_request_prepare', { timeoutMs })
|
||||||
|
progress.requestId = requestId
|
||||||
|
if (settled || signal.aborted) { cancel(); return }
|
||||||
|
progress.issued = true
|
||||||
|
const result = await hostInvoke<T>('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()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user