fix(sync): 恢复过期会话并持久化上传尝试结果

This commit is contained in:
2026-09-08 20:10:26 +08:00
parent 4ff8fdbd70
commit 5a6fa86d10
10 changed files with 325 additions and 41 deletions
@@ -6,7 +6,7 @@
## 当前状态摘要 ## 当前状态摘要
截至 `7b69537`Host Sync capability 已开启,扩展 capability 仍关闭。笔记与 Task CRUD 已接入当前 Rust Vault;同步具备上传/拉取、冲突解决、非空初始合并、100 MiB 续传、主题和编辑器偏好、持久退避。Workspace 当前 schema 9。凭据备份/恢复及 WTS 撤销已实现,但实机锁屏验收仍缺。 截至 `7b69537`Host Sync capability 已开启,扩展 capability 仍关闭。笔记与 Task CRUD 已接入当前 Rust Vault;同步具备上传/拉取、冲突解决、非空初始合并、100 MiB 续传、主题和编辑器偏好、持久退避。Workspace 当前 schema 10(上传尝试记录增量后)。凭据备份/恢复及 WTS 撤销已实现,但实机锁屏验收仍缺。
最近完整测试:后端 900 项(Task 增量后)、前端 517 项、Rust desktop 52 项;后两者在重试持久化增量后复跑。以下各节为历史增量,出现的旧 capability、旧 schema 和旧测试数量描述对应当时状态,以本摘要及后续增量为准。OS 沙箱、Rust 扩展管理、剩余逻辑数据类别、完整故障/性能/发布验收仍未完成。 最近完整测试:后端 900 项(Task 增量后)、前端 517 项、Rust desktop 52 项;后两者在重试持久化增量后复跑。以下各节为历史增量,出现的旧 capability、旧 schema 和旧测试数量描述对应当时状态,以本摘要及后续增量为准。OS 沙箱、Rust 扩展管理、剩余逻辑数据类别、完整故障/性能/发布验收仍未完成。
@@ -129,3 +129,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 使用锁定 packaging 环境重新生成 PyInstaller onedir,清单包含 1523 个文件。构建日志 `.build/core-build-production.log`,产物 `.build/sidecar/dist/opennexus-core``.build/sidecar/manifest.json` - 使用锁定 packaging 环境重新生成 PyInstaller onedir,清单包含 1523 个文件。构建日志 `.build/core-build-production.log`,产物 `.build/sidecar/dist/opennexus-core``.build/sidecar/manifest.json`
- 新增显式打包测试 `cargo test --locked --features desktop --test core_bundle packaged_core_twenty -- --ignored`,调用实际可执行文件并在每次启动前校验完整文件清单。20 个独立临时数据目录、20 个不同会话代际、带鉴权和代际头的 HTTP 健康请求全部成功,逐次退出后端口关闭。测试耗时 95.64 秒,日志 `.build/core-packaged-cold-starts.log`。初版测试遗漏代际请求头导致拒绝,修正测试后通过。 - 新增显式打包测试 `cargo test --locked --features desktop --test core_bundle packaged_core_twenty -- --ignored`,调用实际可执行文件并在每次启动前校验完整文件清单。20 个独立临时数据目录、20 个不同会话代际、带鉴权和代际头的 HTTP 健康请求全部成功,逐次退出后端口关闭。测试耗时 95.64 秒,日志 `.build/core-packaged-cold-starts.log`。初版测试遗漏代际请求头导致拒绝,修正测试后通过。
- 该测试按要求先构建后显式运行,普通源码全目标测试会跳过此入口。Clippy `-D warnings` 通过。此证据来自当前 Windows 开发机,不能替代干净 VM、MSVC 签名安装包、两台真实设备或完整 A-01 验收。 - 该测试按要求先构建后显式运行,普通源码全目标测试会跳过此入口。Clippy `-D warnings` 通过。此证据来自当前 Windows 开发机,不能替代干净 VM、MSVC 签名安装包、两台真实设备或完整 A-01 验收。
## 增量:401 恢复与每作业持久尝试记录
- 协调器对可重放的同步轮次收到 401 时仅强制刷新一次;刷新仍校验设备身份且受凭据锁撤销。刷新失败或第二次 401 返回停止错误,403 不刷新。普通非幂等业务创建没有套用重放包装。
- 真实本地 HTTP 测试先在隔离数据库中提前过期 Access Token,确认读取经一次刷新恢复;删除测试会话后 Refresh 也被拒绝,不重新登录、不注册新设备。另注入连续 401 和 403,分别确认最多两次与一次操作调用。
- Workspace schema 10 保存每个上传作业的累计尝试次数与 running/failed/interrupted/succeeded 结果;普通取消通过析构记录中断,进程强杀则由重开修复。文件已确认而结果未记录时,恢复以已持久的 ack 为准。错误只保存限定机器码;待处理作业最多展示 20 项恢复记录。
- 20 次重开验证尝试累加且冻结的提交载荷不变;取消测试确认无需重启即显示 interrupted。真实 100 MiB 十次强杀后,重开查询准确显示 10 次尝试与 interrupted,最终继续上传、下载与摘要校验通过。测试过程使用独立临时 Vault 和服务账号。
- Rust desktop 全目标 53 项通过,之后新增取消测试单独通过;更新后的真实 HTTP 集成再跑通过。两个 ignored 入口分别是已在前轮显式运行的打包测试和父测试实际驱动的强杀辅助进程。前端 SyncSettings 5 项、两项目 TypeScript 检查及最终 Clippy `-D warnings` 通过。日志 `.build/job-recovery-rust-tests.log``.build/job-recovery-http-tests.log`;本增量无 Python 变更。
- 此增量不等于完整 S-02/S-03 故障矩阵,pull 各边界 20 次强杀、同目标 rename 与历史恢复等完整验收仍需推进。
+21
View File
@@ -158,6 +158,27 @@ pub async fn client(
saved.allow_test_http, saved.allow_test_http,
) )
} }
/// The coordinator must serialize calls. Only use for read-only or durably idempotent work:
/// a 401 repeats the operation once with the same device after rotating its session.
pub async fn authenticated<T, F, Fut>(
credentials: &Credentials,
endpoint: &str,
account: &str,
mut action: F,
) -> Result<T>
where
F: FnMut(SyncClient) -> Fut,
Fut: Future<Output = Result<T>>,
{
let first = client(credentials, endpoint, account, false).await?;
match guarded(credentials, action(first)).await {
Err(error) if error.status == 401 => {
let refreshed = client(credentials, endpoint, account, true).await?;
guarded(credentials, action(refreshed)).await
}
result => result,
}
}
pub async fn logout(credentials: &Credentials, endpoint: &str, account: &str) -> Result<()> { pub async fn logout(credentials: &Credentials, endpoint: &str, account: &str) -> Result<()> {
let client = client(credentials, endpoint, account, false).await?; let client = client(credentials, endpoint, account, false).await?;
// A failed server revocation is reported; the encrypted record remains available for retry. // A failed server revocation is reported; the encrypted record remains available for retry.
+68
View File
@@ -30,6 +30,20 @@ impl SyncError {
} }
} }
type Result<T> = std::result::Result<T, SyncError>; type Result<T> = std::result::Result<T, SyncError>;
struct Attempt<'a, W: WorkspaceAccess> {
workspace: &'a W,
job: &'a Job,
finished: bool,
}
impl<W: WorkspaceAccess> Drop for Attempt<'_, W> {
fn drop(&mut self) {
if !self.finished {
let _ = self
.workspace
.access(|ws| ws.sync_attempt_interrupt(self.job));
}
}
}
pub trait WorkspaceAccess: Send + Sync { pub trait WorkspaceAccess: Send + Sync {
fn access<T>( fn access<T>(
&self, &self,
@@ -309,6 +323,13 @@ impl SyncClient {
if job.state == "conflict" { if job.state == "conflict" {
return Err(SyncError::new("REVISION_CONFLICT")); return Err(SyncError::new("REVISION_CONFLICT"));
} }
workspace.access(|ws| ws.sync_attempt_start(&job))?;
let mut attempt = Attempt {
workspace,
job: &job,
finished: false,
};
let result = async {
if job.operation == "put" && job.base_revision.is_none() { if job.operation == "put" && job.base_revision.is_none() {
self.upload(workspace, binding, &job).await?; self.upload(workspace, binding, &job).await?;
} }
@@ -323,6 +344,16 @@ impl SyncClient {
workspace.access(|ws| ws.sync_ack(&job, &revision))?; workspace.access(|ws| ws.sync_ack(&job, &revision))?;
Ok(true) Ok(true)
} }
.await;
workspace.access(|ws| {
ws.sync_attempt_finish(
&job,
result.as_ref().err().map(|e: &SyncError| e.code.as_str()),
)
})?;
attempt.finished = true;
result
}
pub async fn pull_page( pub async fn pull_page(
&self, &self,
workspace: &impl WorkspaceAccess, workspace: &impl WorkspaceAccess,
@@ -577,3 +608,40 @@ fn identifier(value: &str) -> Result<()> {
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dropping_a_pending_attempt_marks_interruption_without_restart() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
ws.write("cancel.md", "", b"retained", "local").unwrap();
let binding = ws
.sync_bind_empty("https://sync.example", "remote", "account")
.unwrap();
let job = ws.sync_next(&binding.id).unwrap().unwrap();
let workspace = Arc::new(Mutex::new(ws));
let future = async {
workspace.access(|ws| ws.sync_attempt_start(&job)).unwrap();
let _attempt = Attempt {
workspace: &workspace,
job: &job,
finished: false,
};
std::future::pending::<()>().await;
};
assert!(tokio::time::timeout(Duration::from_millis(10), future)
.await
.is_err());
let rows = workspace
.access(|ws| ws.sync_attempts(&binding.id))
.unwrap();
assert_eq!(rows[0]["outcome"], "interrupted");
assert_eq!(rows[0]["attempts"], 1);
assert_eq!(
workspace.access(|ws| ws.read("cancel.md")).unwrap().content,
"retained"
);
}
}
+11 -11
View File
@@ -268,6 +268,11 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
paused, paused,
conflicts, conflicts,
ws.pending_count()?, ws.pending_count()?,
binding
.as_ref()
.map(|b| ws.sync_attempts(&b.id))
.transpose()?
.unwrap_or_default(),
binding binding
.as_ref() .as_ref()
.map(|b| ws.sync_retry(&b.id)) .map(|b| ws.sync_retry(&b.id))
@@ -275,7 +280,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
.unwrap_or_default(), .unwrap_or_default(),
)) ))
})?; })?;
let (vault_id, binding, paused, conflicts, pending, retry) = snapshot; let (vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot;
let credential_state = if let Some(b) = &binding { let credential_state = if let Some(b) = &binding {
sync_auth::available(&host.credentials, &b.endpoint, &b.account) sync_auth::available(&host.credentials, &b.endpoint, &b.account)
.map(|exists| { .map(|exists| {
@@ -293,7 +298,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?; let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?;
let status = binding.as_ref().and_then(|b| statuses.get(&b.id)); let status = binding.as_ref().and_then(|b| statuses.get(&b.id));
Ok( Ok(
json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted}), json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}),
) )
} }
#[tauri::command] #[tauri::command]
@@ -387,15 +392,11 @@ fn now() -> i64 {
async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> { async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> {
let epoch = host.sync.epoch.load(Ordering::SeqCst); let epoch = host.sync.epoch.load(Ordering::SeqCst);
let work = async { let work = sync_auth::authenticated(
let client = sync_auth::client(
&host.credentials, &host.credentials,
&binding.endpoint, &binding.endpoint,
&binding.account, &binding.account,
false, |client| async move {
)
.await?;
let work = async {
client.handshake().await?; client.handshake().await?;
host.workspace.access(|ws| ws.sync_discover(&binding.id))?; host.workspace.access(|ws| ws.sync_discover(&binding.id))?;
for _ in 0..10 { for _ in 0..10 {
@@ -418,9 +419,8 @@ async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> {
} }
client.pull_page(&host.workspace, binding).await?; client.pull_page(&host.workspace, binding).await?;
Ok(()) Ok(())
}; },
sync_auth::guarded(&host.credentials, work).await );
};
tokio::pin!(work); tokio::pin!(work);
let mut tick = tokio::time::interval(Duration::from_millis(50)); let mut tick = tokio::time::interval(Duration::from_millis(50));
loop { loop {
+95 -10
View File
@@ -17,7 +17,57 @@ impl Retry {
.unwrap_or(0) .unwrap_or(0)
} }
} }
fn machine_code(code: &str) -> &str {
if !code.is_empty()
&& code.len() <= 80
&& code
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
{
code
} else {
"SYNC_REMOTE_ERROR"
}
}
impl Workspace { impl Workspace {
pub fn sync_attempt_start(&self, job: &crate::sync_state::Job) -> Result<()> {
self.check_job(job)?;
self.db.execute("INSERT INTO sync_attempts VALUES (?1,?2,1,'running',NULL) ON CONFLICT(binding,operation_id) DO UPDATE SET attempts=MIN(attempts+1,2147483647),outcome='running',error=NULL", params![job.binding, job.operation_id])?;
Ok(())
}
pub fn sync_attempt_interrupt(&self, job: &crate::sync_state::Job) -> Result<()> {
self.check_job(job)?;
self.db.execute("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=?1 AND j.operation_id=?2 AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE binding=?1 AND operation_id=?2 AND outcome='running'", params![job.binding,job.operation_id])?;
Ok(())
}
pub fn sync_attempt_finish(
&self,
job: &crate::sync_state::Job,
error: Option<&str>,
) -> Result<()> {
self.check_job(job)?;
self.db.execute(
"UPDATE sync_attempts SET outcome=?3,error=?4 WHERE binding=?1 AND operation_id=?2",
params![
job.binding,
job.operation_id,
if error.is_some() {
"failed"
} else {
"succeeded"
},
error.map(machine_code)
],
)?;
Ok(())
}
pub fn sync_attempts(&self, binding: &str) -> Result<Vec<serde_json::Value>> {
self.check_binding(binding)?;
let mut statement = self.db.prepare("SELECT a.operation_id,j.path,a.attempts,a.outcome,a.error,j.state FROM sync_attempts a JOIN sync_jobs j ON j.binding=a.binding AND j.operation_id=a.operation_id WHERE a.binding=?1 AND j.state NOT IN ('acked','archived') ORDER BY a.rowid LIMIT 20")?;
let rows = statement.query_map([binding], |row| Ok(serde_json::json!({"operation_id":row.get::<_,String>(0)?,"path":row.get::<_,String>(1)?,"attempts":row.get::<_,i64>(2)?,"outcome":row.get::<_,String>(3)?,"error":row.get::<_,Option<String>>(4)?,"state":row.get::<_,String>(5)?})))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn sync_retry(&self, binding: &str) -> Result<Retry> { pub fn sync_retry(&self, binding: &str) -> Result<Retry> {
self.check_binding(binding)?; self.check_binding(binding)?;
Ok(self Ok(self
@@ -56,16 +106,7 @@ impl Workspace {
return Ok(()); return Ok(());
} }
// Only retain a bounded machine code, never an arbitrary remote response string. // Only retain a bounded machine code, never an arbitrary remote response string.
let code = if !code.is_empty() let code = machine_code(code);
&& code.len() <= 80
&& code
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
{
code
} else {
"SYNC_REMOTE_ERROR"
};
let failures = if code == "CREDENTIALS_LOCKED" { let failures = if code == "CREDENTIALS_LOCKED" {
0 0
} else { } else {
@@ -87,6 +128,50 @@ impl Workspace {
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn interrupted_job_attempts_survive_twenty_restarts_without_changing_commit_base() {
let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap();
ws.write("attempt.md", "", b"body", "local").unwrap();
let binding = ws
.sync_bind_empty("https://sync.example", "remote", "account")
.unwrap();
let job = ws.sync_next(&binding.id).unwrap().unwrap();
let payload = ws.sync_commit_payload(&job).unwrap();
for count in 1..=20 {
ws.sync_attempt_start(&job).unwrap();
drop(ws);
ws = Workspace::open(dir.path()).unwrap();
let rows = ws.sync_attempts(&binding.id).unwrap();
assert_eq!(rows[0]["attempts"], count);
assert_eq!(rows[0]["outcome"], "interrupted");
assert_eq!(ws.sync_commit_payload(&job).unwrap(), payload);
}
ws.sync_attempt_finish(&job, Some("response with secret body"))
.unwrap();
assert_eq!(
ws.sync_attempts(&binding.id).unwrap()[0]["error"],
"SYNC_REMOTE_ERROR"
);
ws.sync_attempt_start(&job).unwrap();
let mut revision = payload.clone();
revision["hash"] = payload["content_hash"].clone();
revision["vault_id"] = serde_json::json!("remote");
revision["sequence"] = serde_json::json!(1);
ws.sync_ack(&job, &revision).unwrap();
drop(ws);
ws = Workspace::open(dir.path()).unwrap();
assert!(ws.sync_attempts(&binding.id).unwrap().is_empty());
assert_eq!(
ws.db
.query_row("SELECT outcome FROM sync_attempts", [], |r| r
.get::<_, String>(0))
.unwrap(),
"succeeded"
);
ws.sync_unbind(&binding.id).unwrap();
assert!(ws.sync_attempt_finish(&job, None).is_err());
}
#[test]
fn retry_history_survives_twenty_reopens_and_isolates_bindings() { fn retry_history_survives_twenty_reopens_and_isolates_bindings() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(dir.path()).unwrap(); let mut ws = Workspace::open(dir.path()).unwrap();
+1 -1
View File
@@ -197,7 +197,7 @@ impl Workspace {
} }
Ok(job) Ok(job)
} }
fn check_job(&self, job: &Job) -> Result<()> { pub(crate) fn check_job(&self, job: &Job) -> Result<()> {
self.check_binding(&job.binding)?; self.check_binding(&job.binding)?;
let state: String = self.db.query_row( let state: String = self.db.query_row(
"SELECT state FROM sync_jobs WHERE binding=?1 AND operation_id=?2", "SELECT state FROM sync_jobs WHERE binding=?1 AND operation_id=?2",
+4 -3
View File
@@ -135,10 +135,10 @@ impl Workspace {
let db = Connection::open(db_path)?; let db = Connection::open(db_path)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if version > 9 { if version > 10 {
return Err(HostError::new("SCHEMA_INCOMPATIBLE")); return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
} }
if (1..9).contains(&version) { if (1..10).contains(&version) {
// Independent, complete SQLite backup before the schema ownership change. // Independent, complete SQLite backup before the schema ownership change.
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4())); let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?; db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
@@ -162,6 +162,7 @@ impl Workspace {
CREATE TABLE IF NOT EXISTS sync_initial_items (binding TEXT NOT NULL,sequence INTEGER NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(binding,sequence)); CREATE TABLE IF NOT EXISTS sync_initial_items (binding TEXT NOT NULL,sequence INTEGER NOT NULL,revision TEXT NOT NULL,PRIMARY KEY(binding,sequence));
CREATE TABLE IF NOT EXISTS payloads (operation_id TEXT PRIMARY KEY,hash TEXT NOT NULL,size INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS payloads (operation_id TEXT PRIMARY KEY,hash TEXT NOT NULL,size INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sync_observed (file_id TEXT PRIMARY KEY,path TEXT NOT NULL,hash TEXT NOT NULL,deleted INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sync_observed (file_id TEXT PRIMARY KEY,path TEXT NOT NULL,hash TEXT NOT NULL,deleted INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sync_attempts (binding TEXT NOT NULL,operation_id TEXT NOT NULL,attempts INTEGER NOT NULL,outcome TEXT NOT NULL,error TEXT,PRIMARY KEY(binding,operation_id));
CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?; CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
@@ -179,7 +180,7 @@ impl Workspace {
if version < 7 { if version < 7 {
db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?; db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?;
} }
db.execute_batch("PRAGMA user_version=9; COMMIT;")?; db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=10; COMMIT;")?;
let vault_id: String = db let vault_id: String = db
.query_row("SELECT id FROM identity", [], |r| r.get(0)) .query_row("SELECT id FROM identity", [], |r| r.get(0))
.optional()? .optional()?
+85
View File
@@ -618,6 +618,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
} }
std::fs::remove_file(root.path().join("interrupt-upload")).unwrap(); std::fs::remove_file(root.path().join("interrupt-upload")).unwrap();
let large_workspace = Arc::new(Mutex::new(Workspace::open(large_root.path()).unwrap())); let large_workspace = Arc::new(Mutex::new(Workspace::open(large_root.path()).unwrap()));
let interrupted = large_workspace
.lock()
.unwrap()
.sync_attempts(&large_binding.id)
.unwrap();
assert_eq!(interrupted.len(), 1);
assert_eq!(interrupted[0]["attempts"], 10);
assert_eq!(interrupted[0]["outcome"], "interrupted");
assert!(client assert!(client
.push_one(&large_workspace, &large_binding) .push_one(&large_workspace, &large_binding)
.await .await
@@ -707,6 +715,59 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
.unwrap() .unwrap()
.unlock(Zeroizing::new(b"fixture-stronghold-password".to_vec())) .unlock(Zeroizing::new(b"fixture-stronghold-password".to_vec()))
.unwrap(); .unwrap();
// Expire the access token early in this isolated fixture; the refresh token stays valid.
let database = rusqlite::Connection::open(root.path().join("sync.sqlite3")).unwrap();
database
.execute("UPDATE sessions SET expires=0", [])
.unwrap();
let attempts = std::sync::atomic::AtomicUsize::new(0);
let recovered = sync_auth::authenticated(&credentials, &canonical, "rust-fixture", |client| {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async move {
client
.json(reqwest::Method::GET, "sync/v1/vaults", None)
.await
}
})
.await
.unwrap();
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
assert!(recovered["items"]
.as_array()
.unwrap()
.iter()
.any(|v| v["id"] == remote));
// Even a repeated 401 must stop after one rotation, rather than refresh indefinitely.
attempts.store(0, std::sync::atomic::Ordering::SeqCst);
let denied = sync_auth::authenticated(&credentials, &canonical, "rust-fixture", |_client| {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async {
Err::<(), _>(notesagent_host::sync_client::SyncError {
code: "UNAUTHORIZED".into(),
status: 401,
retry_after: None,
})
}
})
.await
.unwrap_err();
assert_eq!(denied.status, 401);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
attempts.store(0, std::sync::atomic::Ordering::SeqCst);
let denied = sync_auth::authenticated(&credentials, &canonical, "rust-fixture", |_client| {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async {
Err::<(), _>(notesagent_host::sync_client::SyncError {
code: "FORBIDDEN".into(),
status: 403,
retry_after: None,
})
}
})
.await
.unwrap_err();
assert_eq!(denied.status, 403);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
let restored = sync_auth::client(&credentials, &canonical, "rust-fixture", false) let restored = sync_auth::client(&credentials, &canonical, "rust-fixture", false)
.await .await
.unwrap(); .unwrap();
@@ -723,6 +784,30 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
.status, .status,
401 401
); );
sync_auth::login(
&credentials,
&endpoint,
"rust-fixture",
Zeroizing::new("controlled-fixture-password".into()),
"Revoked Host",
true,
)
.await
.unwrap();
database.execute("DELETE FROM sessions", []).unwrap();
attempts.store(0, std::sync::atomic::Ordering::SeqCst);
let revoked = sync_auth::authenticated(&credentials, &canonical, "rust-fixture", |client| {
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
async move {
client
.json(reqwest::Method::GET, "sync/v1/vaults", None)
.await
}
})
.await
.unwrap_err();
assert_eq!(revoked.status, 401);
assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
} }
#[tokio::test] #[tokio::test]
@@ -71,3 +71,13 @@ it('shows a persisted halt without suggesting an automatic retry countdown', asy
expect(wrapper.text()).not.toContain('120s') expect(wrapper.text()).not.toContain('120s')
wrapper.unmount() wrapper.unmount()
}) })
it('shows persisted per-job interruption counts without starting work from the view', async () => {
vi.mocked(hostInvoke).mockResolvedValue({ ...empty(), binding: { id: 'binding', endpoint: 'https://test.example/', account: 'test', remote_vault: 'remote', cursor: 7 }, attempts: [{ operation_id: 'op', path: 'attachments/large.pdf', attempts: 11, outcome: 'interrupted', error: null }] })
const wrapper = mount(SyncSettings); await flushPromises()
expect(wrapper.text()).toContain('attachments/large.pdf')
expect(wrapper.text()).toContain('尝试次数 11')
expect(wrapper.text()).toContain('从已确认位置恢复')
expect(vi.mocked(hostInvoke).mock.calls.every(([command]) => command === 'sync_status')).toBe(true)
wrapper.unmount()
})
@@ -8,7 +8,7 @@ import { t } from '@/i18n'
const { actionDialog, resolveAction, askConfirm } = useActionDialog() const { actionDialog, resolveAction, askConfirm } = useActionDialog()
interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number } interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number }
interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } } interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } }
interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean } interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> }
interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number } interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number }
const status = ref<Status | null>(null) const status = ref<Status | null>(null)
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false) const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
@@ -129,6 +129,10 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
<button :disabled="busy" @click="unbind">{{ t('解除绑定', 'Unbind') }}</button> <button :disabled="busy" @click="unbind">{{ t('解除绑定', 'Unbind') }}</button>
<button :disabled="busy" @click="act(async () => { await hostInvoke('sync_logout', { endpoint, account }); connected = false })">{{ t('退出登录', 'Sign out') }}</button> <button :disabled="busy" @click="act(async () => { await hostInvoke('sync_logout', { endpoint, account }); connected = false })">{{ t('退出登录', 'Sign out') }}</button>
</div> </div>
<details v-if="status.attempts?.length">
<summary>{{ t('上传作业恢复记录(最多 20 项)', 'Upload recovery records (up to 20)') }}</summary>
<p v-for="job in status.attempts" :key="job.operation_id">{{ job.path }} · {{ t('尝试次数', 'Attempts') }} {{ job.attempts }} · {{ job.outcome === 'interrupted' ? t('上次上传已中断,将从已确认位置恢复', 'Previous upload interrupted; resumes from the confirmed offset') : job.outcome === 'failed' ? t('上次尝试失败', 'Last attempt failed') : t('上传处理中', 'Upload in progress') }}<span v-if="job.error"> · {{ job.error }}</span></p>
</details>
<article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict"> <article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict">
<h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p> <h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p>
<div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div> <div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div>