Skip to content

fix(clipboard): attribute multi-event OS echoes per write instead of one-shot - #51

Open
moekyo wants to merge 8 commits into
UniClipboard:mainfrom
moekyo:fix/clipboard-selfwrite-multi-echo
Open

fix(clipboard): attribute multi-event OS echoes per write instead of one-shot#51
moekyo wants to merge 8 commits into
UniClipboard:mainfrom
moekyo:fix/clipboard-selfwrite-multi-echo

Conversation

@moekyo

@moekyo moekyo commented Aug 21, 2026

Copy link
Copy Markdown

问题类型

结构性缺陷(origin 归因语义:一次性消费 → 按单次写入的回显集)。

根因

一次程序化剪贴板写入可能产生多个 watcher 事件(OS re-encode、双变更通知、剪贴板管理器重断言)。原 InMemorySelfWriteLedger 的 next-change fallback 只吸收第一个事件,第二个事件无 guard 命中 → 被判为 LocalCapturedispatch_local_capture 回传原设备 → 对端 apply 后再写 OS 剪贴板 → 形成 A↔B 图片回环。跨层身份不一致:watcher 用解码像素指纹去重,ledger 用原始字节 hash,re-encode 后 ByContent 永不命中,完全依赖这个一次性 fallback。

用户现象:两台已配对设备,只截一张图 → 本机面板同一张图不断新增、对端不断收到同一张图(捕获/同步风暴)。

方案

  • next-change fallback 按单次写入携带回显预算:远程推送 2 次、本地恢复 1 次(保持原一次性语义)。
  • 内容命中消耗配对 fallback(同 guard key、同 attribution)的一次预算而非删除,第二个 re-encoded 事件仍被正确归因。
  • fallback 消费按内容类别(image: / text: / files: / rich-text: 前缀)匹配,无关类别的事件不再抢占。
  • 首次回显后剩余预算只保留 ECHO_TAIL_TTL(5s),后续无关复制不被吞。

验证

  • cargo test -p uc-infra --lib clipboard::change_origin:12 通过(含新增回环回归用例)。
  • cargo check --workspace --all-targets --locked(585 crates)、cargo fmt --all -- --checknode scripts/architecture/check-engine-repository.mjs 通过。
  • 跨层探针(真实 watcher + 真实 ledger,A↔B 双设备模拟):修复前 6 hops 每跳 bounce(无限环);修复后 0 bounce,环在第一跳终止。
  • 真机验证(macOS + Windows 双端):两端各自原生构建安装器(desktop 临时分支 pin 本 PR 的 fork commit),配对后 mac→win 与 win→mac 各复制多次图片,对端每次只收到一张,无捕获风暴、无回环,静置后不再新增 entry。
  • 备注:uc-infra 全量测试中 rendezvous::client::tests::transport_failure_maps_transport_variant 在本机失败,已在基础提交(31c149c)上复现,与本改动无关(环境相关)。

文档

已按仓库规范在 docs/architecture/architecture-bible.md 文档维护记录中登记本次语义变化。

…one-shot

A single programmatic clipboard write can surface as several watcher
events (OS re-encode, double change notification, clipboard-manager
re-assertion). The one-shot next-change fallback absorbed only the
first; the second was mis-attributed as LocalCapture and dispatched
back to the sender, sustaining an A<->B image echo loop.

The fallback now carries an echo budget per write (two credits for
remote pushes, one for local restores), a content match spends one
credit of the paired fallback instead of deleting it, fallback
consumption is scoped to the same content kind (image/text/files/
rich-text), and the leftover credit expires ECHO_TAIL_TTL after the
first echo so an unrelated later copy is not swallowed.

Local restore semantics are unchanged (one-shot).
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4aabef65-f1e5-4338-9ea4-b728cc82b530


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@moekyo moekyo closed this Aug 21, 2026
@moekyo moekyo reopened this Aug 21, 2026
@moekyo

moekyo commented Aug 21, 2026

Copy link
Copy Markdown
Author

真机验证记录(macOS + Windows 双端)

  • 测试包:desktop 临时分支 moekyo/UniClipboard@test/fix-image-echo-loop-fork-pin(pin 本 PR 的 fork commit b88a8a61),两端各自原生构建安装器(mac dmg + Windows NSIS)。
  • 场景:两台已配对设备,一台复制一张图片。
  • 结果:对端每次只收到一张;重复多次(mac→win、win→mac)均只出现一次,无捕获风暴、无回环;静置后两端不再新增 entry。
  • 修复前基线:相同代码(31c149c)下同一探针模拟 6 hops 每跳 bounce(无限环);本 PR 后 0 bounce。

@moekyo

moekyo commented Aug 21, 2026

Copy link
Copy Markdown
Author

@mkdir700 麻烦 review 一下这个修复。问题:两台已配对设备只截一张图 → 本机面板同一张图不断新增、对端不断收到同一张图(捕获/同步风暴)。根因:SelfWriteLedger 一次性归因无法吸收一次写入产生的多个 OS 事件,第二个事件被判 LocalCapture 回传对端形成 A↔B 回环。已附单元测试、跨层探针和 macOS+Windows 真机验证记录。

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…g recovers

A crash mid-admission (e.g. Windows blue screen during a join) leaves a
durable 'admission in progress' record in the admission store. The
last-resort /encryption/factory-reset path wiped key material and peer
state but never cleared that record, so every later pairing attempt
failed on both sides ( /
with AdmissionInProgress) and the user was locked out of re-pairing.

factory_reset now clears the admission store through the same
clear_admission_state entry used by the device-management reset; a
failed admission-store reset surfaces as AdmissionStateResetFailed
instead of being swallowed.
@moekyo

moekyo commented Aug 22, 2026

Copy link
Copy Markdown
Author

补充第二个修复(同分支):factory reset(忘记口令兜底路径)现在会清理持久化的 admission 状态 —— 崩溃/蓝屏中断加入流程留下的 "admission in progress" 记录此前会导致重置后仍无法重新配对,现已修复。

moekyo added 6 commits August 23, 2026 07:02
A crash or interruption during pairing leaves a durable pending
admission attempt that blocks every later pairing attempt. Add a
lightweight recovery operation that clears only the non-terminal
attempts - terminals, membership history, consumed invitations and the
device trust revision of an intact space are preserved.

- admission_attempt_store: clear_pending_admissions (attempts.retain
  non-terminal only)
- port: AdmissionAttemptRepositoryPort::clear_pending_admissions
- convergence: WorkspaceConvergence::clear_pending_admissions +
  ProfileWorkspaceConvergence::clear_stale_admission
- engine: Operation::ClearStaleAdmission -> OperationResult::StaleAdmissionCleared
… swallowing

Windows joiner fails at 'prepare Candidate: space transition storage
failed' because init_db_pool (SqliteConnection::establish + WAL pragma)
errors are collapsed to the unit AdmissionSpaceTransitionError::Storage,
hiding the real diesel/libsqlite error. Log the underlying error plus
the target database path and space id before collapsing; same for the
manifest-store load in prepare_if_needed.
The 'space transition storage failed' on Windows joiner is collapsed at
three unlogged points, hiding the real sqlite/diesel error:
- prepare_if_needed now logs the branch decision (source space vs
  target space, manifest presence)
- prepare_source failures now log the underlying String error plus
  generation/attempt ids
- snapshot_to_bytes logs the diesel error for VACUUM INTO (e.g. 'cannot
  VACUUM from within a transaction', 'database is locked') with the
  scratch path and SQL
…torage

The Windows joiner failure ('space transition storage failed') was
invisible because every underlying sqlite/diesel/fs error was collapsed
into a unit Storage variant and the diagnostic tracing line could be
dropped by the lossy non-blocking writer. Make the error itself carry
the detail: Storage is now Storage(String) via a storage() constructor,
so the real failure text survives map_space_transition_error ->
WorkspaceConvergenceError::AdmissionStorage and appears in the
join-space error line and UI regardless of log writer behavior.
Windows joiner failed at 'space transition storage failed: open
generation parent': std::fs::File::open on a directory requires
FILE_FLAG_BACKUP_SEMANTICS, which the standard API does not pass, so
opening the generation directory for fsync always fails with access
denied on Windows (macOS/Linux allow read-only directory opens). Use the
same pattern as active_space_manifest_store::sync_parent_directory:
Unix opens+syncs the directory; Windows skips it (renames are already
durable via MOVEFILE_WRITE_THROUGH).
@moekyo

moekyo commented Aug 23, 2026

Copy link
Copy Markdown
Author

Windows joiner 修复验证通过(7efa66ec)

问题:Windows 作为 joiner 加入空间必败 space transition storage failed: open generation parent,mac 成功→Windows 失败(Windows 专属)。

根因:write_new_file 对 generation 目录做 fsync 时 std::fs::File::open(parent) —— Windows 打开目录需 FILE_FLAG_BACKUP_SEMANTICS,标准 API 不带 → 每次 access denied(Unix 允许只读打开目录)。workspace-state.bin 已写完、rename 也成功,卡在最后的目录 fsync。

修复:复用 active_space_manifest_store::sync_parent_directory 既有模式——Unix 保留目录 open+fsync,Windows 跳过(rename 已由 MOVEFILE_WRITE_THROUGH 保证持久性)。

验证:Windows 实机以 joiner 身份用邀请码加入 → 成功打通(此前 5 个版本必败)。

@mkdir700

Copy link
Copy Markdown
Member

感谢你的 PR,这两天我在重新梳理 application 层(AI 写的太乱了),不可避免会有冲突,所以 PR 暂时无法合并

@moekyo

moekyo commented Aug 24, 2026

Copy link
Copy Markdown
Author

感谢你的 PR,这两天我在重新梳理 application 层(AI 写的太乱了),不可避免会有冲突,所以 PR 暂时无法合并

我自己先打包用着了,不急的 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants