| name | race-check |
| description | worker スレッド・channel・フレーム drain・Tauri listener・スレッド/窓をまたぐ共有状態・フレーム内 live-read を追加/変更したとき、または async 関数を追加/変更したとき、あるいは計画レビュー時に使用。送信から適用までの窓での状態競合リスクを検証する。 |
| argument-hint | [対象: 境界の説明, 例: 'spawn_folder_load: FolderMsg を共有 channel へ送り update() が drain'] |
| allowed-tools | ["Read","Grep","Glob"] |
$ARGUMENTS の変更について、並行境界(別スレッド・別フレーム・別窓へ渡る地点)での状態競合リスクを検証する。
$ARGUMENTS が空の場合は、会話の直近の変更内容から対象を推定する。
実装後のコードレビューだけでなく、workspace/plan.md の計画レビューにも使える。計画段階で「この境界は安全か?」を検証し、見落としがあれば計画を更新してから実装に進む。
背景
このプロダクトのレースコンディションは「送信してから適用されるまでの間に世界が変わりうる」という1点に帰着する。await を数える検査は当たらない——UI 層の並行性は worker スレッド + channel + フレーム drain にある。
前提となる土台が 3 つある(要点のみ再掲する。正本は src-tauri/CLAUDE.md「イベント駆動 wake の不変条件」節と snotra-egui-runtime/CLAUDE.md の不変条件で、内容が食い違ったら正本が正しい):
- view-local の UI 状態(各 view の
&mut self)を直接触ってよいのはイベントループスレッドだけである。両窓の update() は同一スレッドで走る。窓 API・managed state はこの限りでない——別スレッドからも触られる(→ 境界 ⑤⑥)。
- フレームは勝手に回らない(イベント駆動ランタイム)。状態を変えても、誰かが次フレームを起こさなければ画面に出ない。
listen のコールバックは emit した呼び出し元スレッド上で同期実行される——別スレッドへ dispatch されない(正本は src-tauri/CLAUDE.md「Win32 メッセージ配送の注意」節)。ゆえに listener の追加は、emit 元のスレッド(Win32 メッセージループ・config 監視・index build)から UI 状態を触るコードの追加と同義である。これは反直観であり、最も見落とされる境界である。
Step 1 — 並行境界の列挙
まず対象を確定する。
- 実装レビュー:
git diff 等で実際に差分を取得し、$ARGUMENTS の記述と突き合わせる。$ARGUMENTS の散文を差分の定義に代用しない(同じ変更に含まれる他ファイルへの波及が俎上に載らない)
- 計画レビュー(
workspace/plan.md 等): 対象は計画の記述そのものである。計画が述べる設計上の操作(spawn する・送る・listen する・共有状態を足す)をテキストから直接同定する。計画が新設する記述はコードにまだ無い——grep の結果を根拠に「非該当」と報告してはならない(既存関数に足す計画なら、同じファイルの既存ヒットが「実装済み」に見える誤読も起きる)
そのうえで、次の 7 種を列挙する(grep はシンボル名で行う。行番号で位置を断定しない):
| # | 境界 | grep の手がかり |
|---|
| ① | worker の spawn | thread::spawn, async_runtime::spawn |
| ② | channel への send | \.send\(, Sender, channel\( |
| ③ | フレームでの drain | try_recv, Receiver |
| ④ | managed state の読み書き(ロック取得順序・保持区間は本スキルの対象外——snotra-core/CLAUDE.md「engine.rs のロック最小化パターン」が正本) | try_state, Mutex, Atomic, \.lock\( |
| ⑤ | Tauri listener / emit | \.listen\(, \.emit\(, events:: |
| ⑥ | channel を経由しない worker(managed state や窓 API を直接叩く監視・再スキャン系スレッド) | ① のうち送信を持たないもの |
| ⑦ | .await 地点(少数だが実在する) | \.await |
paint より後に走るものも境界である(遅延 dispatch・クリックハンドラ・request_repaint_after の期限処理)。同一フレーム内でも「描いた後に状態が変わる」なら次フレームの問題になる。
境界 1: <種別> — <シンボル> — <何が誰へ渡るか>
境界 2: ...
1 つの worker が複数の種別を同時に持つなら、種別ごとに別の行を立てる——spawn した本体の中で send もし managed state へも直書きする、は 3 つの境界である。1 行にまとめると、そのうち 1 つ(多くは channel)の staleness だけ見て満足する。worker 本体の中でのそれらの実行順序も 4d の対象である。
列挙の母集団
変更した hunk が触れる行と、変更したシンボルが読み書きする状態が母集団である。同じファイルに居るだけの無関係な既存ヒットは境界ではない——update() を持つファイルは Mutex だけで数十件ヒットするため、ファイル全体を母集団にすると 1 関数の変更で数十件の裁定義務が生まれ、検査が実行不能になる。
非該当と結論してよい条件(厳格)
- ①〜⑦の全種について、上記の母集団(変更 hunk + 変更シンボルが読み書きする状態 + 1 ホップ先の呼び出し元)に対して grep を実行し、0 件であることをコマンドとともに示す。①②③だけ見て「spawn も channel も無い」と書くのは非該当判定ではない。ファイル全体を母集団にしない——同じファイルに居るだけの既存ヒットで 0 件が出せなくなり、非該当ルートが構造的に閉じる
- 計画レビューではこの grep 条件を使わない。計画テキストから spawn / send / listen / 共有状態の追加の記述を同定し、いずれも無いことを計画の引用で示す
- 対象は差分の全ファイル・全 hunk である。目についた 1 ファイルを「対象」と自己申告しない
- 変更したシンボルの呼び出し元を 1 ホップ辿る。純粋核(①〜⑦を 1 つも含まないファイル)であっても、そのホップ先が①〜⑦のいずれかに直接該当するなら、そのホップ先を境界として Step 2 へ進む。該当しなければ非該当としてよい(遡行は 1 ホップで打ち切る——この規模のアプリでは辿り続ければ必ず
update() に着くが、update() 自体はどの境界種別でもない。無限遡行も恣意的な打ち切りも避ける)
- 呼び出し元を見る理由は、定義ファイルの grep が空振りすることと並行性に無関係であることが別だからである。純粋関数であっても、それが staleness 判定・可視性/表示ゲート述語・世代の照合のいずれかに使われているなら境界の要石である
すべて満たしたときだけ「並行境界なし(非該当)」と報告する。
Step 2 — 各境界の staleness 機構の同定
送信と適用の間に世界が変わったとき、古い結果をどう落とすか。現行の代表的な 4 型(網羅ではない。5 型目以降を見つけたらそう報告する):
| 型 | 成立条件 | 例(2026-07 時点) |
|---|
| 世代 token | channel が view 寿命の共有。メッセージに token を載せ、drain で現行世代と照合して捨てる | folder ナビ(accept_folder_result) |
| チャネル所有権 | channel が per-request。受信側の構造体が Receiver を所有し、破棄すると遅着 send は Err で自然消滅する | 起動(LaunchInFlight) |
| 重複 spawn ガード | 結果がキーで冪等。staleness は無害で、代わりに in-flight 集合で同一対象の多重 spawn を防ぐ | アイコン抽出(icon_pending) |
| level-triggered 状態 | 値を共有スロットへ直書きし、読み側が毎フレーム現在値を読む | updater の phase |
判定軸は「送信路が view 寿命の共有か、per-request か」である(送信路は channel とは限らない——managed state の共有スロットも同じ役を果たす)。共有なら token が要り、per-request なら所有権の破棄で足りる。この 2 つを混ぜてはならない(per-request に token を足すのは冗長、共有から token を落とすのは破れ)。
世代 token は channel 専用の機構ではない——共有スロットへ直書きする level-triggered 型が、その値の一部として世代を運ぶこともある。「channel が無いから世代の話ではない」と読まないこと。
窓をまたぐ共有スロットは、運ぶ値が世代を持つかを個別に問う——同じ構造体の中でも、世代を運ぶフィールドと運ばないフィールドが同居しうる。裸の index・裸の添字は「受け側フレームで対象集合が入れ替わっていない」ことを暗黙に仮定している。境界チェック(.get())は存在の確認であって同一性の確認ではない。
Step 3 — 窓の間に到達しうる事象の列挙
各境界について、送信〜適用の間に共有状態を変えうる経路を grep で探す。主な経路:
| 経路 | トリガー | 影響 |
|---|
| 打鍵 → 検索/フィルタ | ユーザー入力(毎フレーム受け付ける) | クエリ・結果・選択・世代 |
| Enter / クリック → 起動 | ユーザー操作(クリックは別窓から遅延 dispatch で戻る) | in-flight 状態・結果クリア |
| Escape / ← → モード離脱 | ユーザー操作 | view 種別・世代の失効 |
| hide / show | ホットキー・blur・起動完了(emit 元は別スレッドでありうる) | reset-on-show による全 view-local の一掃 |
| config 適用 | ファイル監視スレッド | テーマ・フォント・言語・幅・index 再構築 |
| index build 完了 | build スレッド | 世代 bump → 再検索 |
| 他 worker の到着 | 別の spawn | 結果の総入れ替え |
この表は例示であって網羅ではない。 対象コードに現れる入力ハンドラ・イベント名を grep し、表に無い経路が無いことを示す(表の 7 行を裁定して終えるのは、模範解答を網羅と取り違えている)。
各経路がその窓の間に実際に到達可能かを判定する(ガードの有無を確認する)。到達不能と結論するなら、何が塞いでいるかを名指しし、そのガードの条件式(if / match / 早期 return)を file:シンボル か grep 結果で示す——名前を挙げるだけでは、そのガードが当の経路を実際に止めている確認になっていない。
Step 4 — 5観点での検証
全面適用は差分が新設・変更した境界に限る。 既存のまま触っていない境界は 1 行の理由(「この差分は触れていない」)で足りる。境界数 × 観点数 × 証拠 2 種の積は容易に破綻し、破綻した検査は「大半が [要確認]」という行動不能な報告を生む。
4a. wake 義務
状態を変えた後、次フレームを起こす者がいるか(回数ではなく到達性を問う。送信が 1 バーストで完結し、最後の送信より後に repaint が来るなら 1 回で足りる。逐次 send が長時間続くなら、最後の 1 回では途中の到着が見えない)。
- 自窓の
egui::Context を持つ場所(update() 内・worker へ渡した clone)は ctx.request_repaint()
- 外部スレッド・別窓・listener からは wake handle(
WindowWaker)を使う
- managed state や外部へ渡すハンドルに
egui::Context の clone を置いていないか——clone は repaint callback ごと複製し、worker の停止・join を妨げる(snotra-egui-runtime/CLAUDE.md の不変条件)
- hidden 窓への wake は実効 no-op でよい(次の show で live-read が拾う)が、それを前提にしてよいのは値を運ばない wake だけである
- listener が自ら起こさない設計は、それ自体では問題ではない——別のイベントへ wake を委譲していることがある。その場合は委譲先のイベントが全終端で無条件に伴走することを emit 側の制御フローで確認する。コメントの記述を根拠にしない。確認せずに
[問題] と書けば誤検出、コメントを鵜呑みにして [OK] と書けば未確認である
4b. staleness 適用
Step 2 で同定した機構が、実際に適用されているか。
- 共有 channel: drain で token を照合して捨てているか。滞留を全部 drain して最新を採っているか
- per-request channel: 受信側の破棄が「捨てる」全経路(成功・timeout・異常終了・リセット)に置かれているか。enum の全アームを尽くしたか
- 世代カウンタ: 書き側の全サイトが進めているか
- 重複 spawn ガード: in-flight への insert と、全終端での remove が対になっているか
- single-flight(再入拒否): 拒否のフラグを戻す経路が全終端にあるか——1 つ欠けると永久に拒否し続ける
- level-triggered 状態: 書き手が複数(worker・UI スレッド・別 listener)いるとき、後着の書き込みが先着の意味のある遷移を黙って上書きしないか。進行中の状態を無関係な完了通知が巻き戻す形になっていないか
4c. 窓の間に届く事象への耐性
Step 3 で到達可能とした事象それぞれについて。
- ユーザー操作: ロード未確定・in-flight 中の古い対象に対する操作を弾いているか(弾かないなら、古い対象に作用しても無害である根拠をコードで示す——対象キーの比較・冪等性を保証する条件式を
file:シンボル で。「キーで冪等だから無害」のような一般論は根拠ではない)
- hide / show を跨ぐか: hidden 中は
update() が走らないため、期限つき再描画による時限処理は可視中しか効かない。hide を跨ぐ in-flight 状態は reset-on-show の backstop とセットで設計する(正本は src-tauri/CLAUDE.md「イベント駆動 wake の不変条件」節)
- 判定は「クリアされるか」ではなく「クリアされるか、されない理由が書かれているか」である——意図的に show を跨がせる状態(hidden 中に起きた通知を次の show で見せる等)が実在するため、クリアを一律の要件にすると誤検出になる
- クリア対象の列挙は view-local だけで閉じない——managed state 側に置かれた共有スロットは reset の視野に入っていないことがある
4d. 順序不変条件
この処理を別の位置へ動かすと壊れるか。 壊れるなら、その順序はコメントで明文化されているか。
update() の中だけの話ではない——listener / emit ハンドラ・show/hide の同期関数・worker 本体の中の逐次処理も、すべて順序不変条件を持ちうる。update() に限って読むと、リスナー経由で呼ばれる関数の内部順序が丸ごと検査から落ちる。
既知の型(正本は各コメント): 消費は初期化・リセットの後/格納は表示・wake の前/主窓の可視フラグと従属窓の show/hide は、show と hide で逆向き(主窓が可視でない期間に「可視」と読ませない向きへ倒す。主窓自身の show/hide だけの話ではなく、別窓の表示ゲートが読む値であることが要点)。
「壊れない」と判定する場合も根拠が要る——その処理が読み書きする状態と、前後の処理が読み書きする状態に重なりが無いことを grep で示す。重なりを確認せずに「独立した処理だから動かしてよい」と断定しない。
新しい処理を足すときは、リセット・消費・描画のどれとの前後が意味を持つかを明示する。
4e. 同一フレーム内の live-read 規律
- 同じ設定値をフレーム冒頭で読んだら、後段で読み直さない(間に適用が挟まると同一フレーム内で新旧が混ざる)
- 読み取り結果を
self. へ保持しない(毎フレーム live-read が設定変更の反映経路そのもの)
- 逆に、フレームを跨いでキャッシュして hot-reload を殺していないかも見る
Step 5 — 各境界の判定
境界 1: <種別> — <シンボル> — <説明>
4a wake 義務: [OK] worker が送信後に ctx.request_repaint()
[問題] listener が状態を書くだけで誰も起こさない
4b staleness: [OK] 共有 channel + token を drain で照合
[問題] per-request なのに token を足している / 世代の書き側が 1 箇所抜けている
4c 窓の間の事象: [OK] ロード未確定はガードで弾く・reset-on-show でクリア
[問題] hide を跨いだ in-flight のクリア経路が無い
4d 順序: [OK] リセット消費の後に置き、理由をコメント済み
[問題] 位置に依存するが明文化が無い
4e live-read: [OK] フレーム冒頭で 1 回だけ読む
[問題] 同じ設定を後段で読み直している
総合判定: [安全] / [要修正: <具体的な修正内容>] / [要確認: <未確認の項目>]
[要確認] が 1 つでも残る境界は [安全] にしてはならない。 [問題] が 1 つも無いことは、[安全] の条件ではない——確認していない項目があるなら、その境界の総合判定は [要確認] である。「面倒な観点を [要確認] に倒し、[問題] が無いので安全」は、この検査を無効化する最短経路である。
出力
根拠の規律: 全判定に、file:シンボル と、その判定を支える実際のコード片(該当行)の両方を付ける。場所の名前だけでは、そこに何が書かれているかを確認した証拠にならない。コードを確認せずに下した判定は [OK] とせず [要確認] として報告する。行番号で位置を断定しない——挿入でずれる。
grep 結果を根拠にするときは総ヒット件数を明記し、全件について個別に判定する(対象は Step 1 で境界と確定したものに限る。同じファイルに居るだけの無関係なヒットは母集団ではない)。1 件だけ貼って残りを黙って無視するのは、形式を満たした未確認である。
Step 1 で列挙した境界の数と、Step 5 のマトリクスの行数は一致させる。 統合・除外したなら、その理由を Step 5 に書く——正直に列挙したあと途中で 1 行落とすのは、入口ではなく出口での取りこぼしである。
全境界の判定をマトリクス形式でまとめる。
問題が見つかった場合は修正案を提示する。
全境界が安全な場合は「全境界で状態競合リスクなし」と明示する。
Step 1 が空だった場合は「並行境界なし(非該当)」と明示する——検査を実行したことと、検査対象があったことは別である。