Skip to content

perf: run native clipboard writes off the client event loop - #58

Merged
peinan merged 3 commits into
mainfrom
issue/55-async-clipboard
Oct 5, 2026
Merged

peinan merged 3 commits into
mainfrom
issue/55-async-clipboard

Conversation

@peinan

@peinan peinan commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

コピーのたびに client のメインループがネイティブのクリップボードツール(macOS では pbcopy)の終了を同期で待っていたのを、専用のワーカースレッドへ移す。

何が問題だったか

  • crate::selection::write_osc52_bytes は client のメインイベントループ上で platform::write_clipboard を呼んでいた。macOS では pbcopy を spawn し、stdin に書き、child.wait() で終了まで待つ。実測で 1 回 14〜21 ms。
  • 呼び出し元は client 側の 2 か所(ClientShellAction::ClipboardWrite の dispatch と、ServerMessage::Clipboard を受けた forward_clipboard)。待っている間は入力処理もフレーム描画も止まる。Claude Code でのコピーでは Claude Code 自身の pbcopy と合わせて 2 回走るので、toast 直後のもたつきの一因になっていた。
  • Linux も同じ構造で、wl-copy の起動待ちでは最大 100 ms ループ上で待っていた。

変更

  • 新規 src/client/clipboard_writer.rs(platform cfg なし)に ClipboardWriter を追加した。
    • ネイティブ書き込みは clipboard-writer スレッド 1 本と FIFO(std::sync::mpsc)で順に実行する。ループはキューに積むだけで待たない。
    • 遅いネイティブ書き込みの後ろに溜まったコピーは、最新の 1 件にまとめて書く(coalescing)。クリップボードに残るのは最新のコピーだけなので、連続コピー(nvim のマクロで OSC 52 の yank が数百回続く場合など)でネイティブツールを件数分起動せずに済み、連続コピーの途中で detach しても最新のコピーは書かれる。Flush はその前に積まれた最新の書き込みを済ませてから完了する。
    • ネイティブが失敗したら ClientLoopEvent::ClipboardFallback(bytes) でループへ返し、ループが OSC 52 を書く。stdout に書くのはループだけだから。
    • フォールバック待ちが残っている間(pending_fallbacks > 0)は、後続のコピーもネイティブを試さずフォールバック側に並べる。古いコピーが後から OSC 52 で上書きする逆転を防ぐため。待ちが解消すれば次のコピーからネイティブに戻る。
    • 一度の失敗で恒久的に OSC 52 へ切り替えることはしない。Windows の OpenClipboard は他アプリが握っているだけで一時的に失敗し、OSC 52 は conhost や Terminal.app では無視されるため。
    • SSH / WSL / VS Code(should_prefer_osc52())ではワーカーを作らず、従来どおりループ上で OSC 52 を即書きする。判定は client 起動時に 1 回だけ行う(環境変数も WSL 判定もプロセスの途中で変わらない)。
    • Drop 時は Flush ジョブを送って最大 500 ms 待つ。detach 直前のコピーを失わないため。そのあとループに返したフォールバックがまだ書かれずに残っていれば、書かれないことを warn で残す。
  • ClientState に clipboard を持たせ、dispatch_client_shell_actions に &ClipboardWriter を渡す。
  • forward_clipboard(&ClipboardWriter, data) は「キューに積めたら true」を返す。toast を出すタイミングは今のフォークと同じ。
  • イベントチャネル(event_tx / event_rx)の生成を ClientState の初期化より前へ移した。ワーカーが自前の sender を持つため。
  • src/selection.rs: write_osc52_bytes を write_osc52_sequence(stdout に OSC 52 を書くだけ)に置き換え、should_prefer_osc52 を pub(crate) にした。名前を残して中身だけ変えると、将来 upstream 側で増えた呼び出しが黙ってネイティブ経路を失うので、名前ごと置き換えた。

検証

  • cargo fmt --check、cargo clippy --all-targets --locked -- -D warnings(macOS): OK
  • cargo clippy --bin herdr --locked --target x86_64-pc-windows-msvc -- -D warnings: OK
  • cargo nextest run --no-fail-fast: 3353 tests run, 3351 passed, 2 failed。
    • 失敗は live_handoff::live_handoff_preserves_pane_process_io と live_handoff::live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session。
    • どちらも FORK_WORKFLOW.md §0 の環境依存の既知失敗で、herdr server だけを起動するテストなので client の変更は関与しない。
  • scripts.test_ui_hot_path_architecture: OK
  • 追加したテスト(ネイティブ書き込みは注入した関数で検証し、実際のクリップボードには触れない。待ち合わせは Drop の 500 ms 上限に頼らず flush を明示的に呼ぶ):
    • native_writes_run_in_order_on_the_worker_thread — Flush で区切った書き込みが、その順に clipboard-writer スレッド上で実行される
    • writes_queued_behind_a_slow_native_write_collapse_to_the_newest — 1 件目のネイティブ書き込みが止まっている間に 2〜5 件目と Flush を積むと、ネイティブは 1 件目と 5 件目だけで呼ばれ、Flush は 5 件目を書いてから完了する
    • failed_native_write_holds_later_writes_until_its_fallback_is_written — 失敗時のフォールバック、pending 中の後続はネイティブを試さないこと、解消後にネイティブへ戻ること
    • preferred_osc52_writes_inline_without_native — OSC 52 優先時は即書きし、ネイティブを呼ばない
    • flush_waits_for_a_slow_native_write / flush_times_out_on_a_hung_native_write — Flush の待ち合わせとタイムアウト、止まった書き込みが戻ればワーカーが動き続けること
    • forwarded_clipboard_is_decoded_and_queued / invalid_clipboard_payload_writes_nothing
    • client/tests/mod.rs の forward_clipboard_uses_local_clipboard_path は upstream eef1914 と同じ形で削除した(次の同期で衝突させないため)。
  • PTY ハーネス(release ビルド)。ハーネスは VSCODE_IPC_HOOK_CLI で OSC 52 経路を強制するので、ループ上の即書き経路と、コピー・toast の回帰を確認した。
    • copy --rounds 3 --text-mode static: 9/9 matched
    • copy-click --rounds 6: 6/6 focused、p50 12.5 ms / max 15.3 ms
    • app --rounds 6 --drag: 6/6 focus switches、p50 13.0 ms / max 15.6 ms
    • real --rounds 4 --panes 3 ...: 0 failures、focus shown p50 14 ms / max 19 ms
  • coalescing の追加コミットで確認したこと(上のテスト件数とハーネスは 1 コミット目の結果):
    • cargo fmt --check、cargo clippy --all-targets -D warnings(macOS)、Windows ターゲットの cargo clippy --bin herdr -D warnings: OK
    • クリップボードのテスト 8 件を --stress-count 30: 30/30 iterations passed
    • client のテスト(-E 'test(/client::/)'): 561 passed

残課題・注意点

  • 次の upstream 同期で手動の衝突解消が要る。
    • upstream eef1914(フォーク未取り込み)は write_osc52_bytes を bool を返す形にし、クリップボードが同じ内容なら書かない(clipboard_text_matches、実装は Windows のみ)。本 PR で write_osc52_bytes を消したので、selection.rs と clipboard_forwarding.rs が衝突する。
    • 重複抑止はワーカーのネイティブ書き込みの直前に移すのが自然だが、upstream は重複時に false を返して toast を出さない。キューに積んだ時点で toast を出す本 PR の形とは合わないので、その扱いは同期時に決める必要がある。
    • upstream では dispatch_client_shell_actions の event_tx 引数が scheduled_activation に変わっているので、引数リストも小さく衝突する。
  • コピーの toast はキューに積んだ時点で出し、ネイティブ書き込みの完了は待たない。ネイティブが失敗しても OSC 52 にフォールバックするので、toast が出てコピーが落ちるのは「ネイティブが失敗し、かつ端末が OSC 52 に対応していない」場合だけで、これは従来と同じ。
  • 端末は OSC 52 を非同期に処理するので、フォールバックの OSC 52 の直後にネイティブ書き込みが来ると、理論上は pbcopy が先に届きうる。人の操作間隔では起きない。
  • ネイティブが失敗し、その OSC 52 フォールバックをループが処理する前に detach すると、そのコピーは失われる(終了時に warn を残す)。
  • ネイティブツールが終わらなくなった場合(pbcopy / xclip が exit しない)、以後のコピーはそのセッションの間ずっと、その後ろで黙って待つことになる。従来はクライアント全体が固まっていたので、それよりは軽い(終了時の待ちも 500 ms で打ち切る)。タイムアウトや kill は入れていない。入れるには upstream 管理の platform コード(run_clipboard_command)を変える必要があるため。
  • Windows の conhost(OSC 52 非対応)では、OpenClipboard が一時的に失敗したとき、そのコピーに加えて次のコピーも失われうる。1 件目のフォールバックをループが書く前に次のコピーがワーカーに取り出されると、pending 中としてネイティブを試さずフォールバックに回るため。

refs #55

🤖 Generated with Claude Code

pbcopy, wl-copy and xclip are separate processes and the client waited for
each on its event loop, 14-21 ms per copy on macOS. Native writes now run in
order on one worker thread; a failed write returns to the loop as OSC 52,
since only the loop writes stdout, and later writes queue behind it so an
older copy cannot land last. SSH, WSL and VS Code sessions keep writing OSC
52 inline. Shutdown briefly waits for queued writes.

refs #55
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 51 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: b85715ab-ce8c-4448-a46e-bc2ea815b8d4
📥 Commits

Reviewing files that changed from the base of the PR and between a467b7a and c1efcf5.

📒 Files selected for processing (10)
  • src/client/catalog_reload.rs
  • src/client/clipboard_forwarding.rs
  • src/client/clipboard_writer.rs
  • src/client/events.rs
  • src/client/mod.rs
  • src/client/shell/tests/endpoint_requests.rs
  • src/client/shell_runtime.rs
  • src/client/state.rs
  • src/client/tests/mod.rs
  • src/selection.rs
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

peinan added 2 commits October 5, 2026 13:55
Only the newest copy matters to the clipboard, so a burst of copies no
longer runs one native tool per write in the background, and a detach in
the middle of a burst keeps the newest copy. Shutdown warns when fallback
copies are still waiting for the loop.

refs #55
@peinan
peinan merged commit 910f021 into main Oct 5, 2026
6 checks passed
peinan added a commit that referenced this pull request Oct 5, 2026
Log #56, #57 and #58 under a new fork.10 generation, and note in the
workflow that the 15.4 SDK is gone, with the prebuilt libghostty-vt
workaround that keeps builds and tests running until the next upstream
sync.
@peinan
peinan deleted the issue/55-async-clipboard branch October 5, 2026 10:38
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.

1 participant