Skip to content

feat(rpc): #56 の要求・応答の形と、ホスト・クライアント側の照合(PR (a)) - #67

Merged
fukuyori merged 5 commits into
fukuyori:mainfrom
nick20002005:fix/rpc-request-owner-56
Sep 30, 2026
Merged

fukuyori merged 5 commits into
fukuyori:mainfrom
nick20002005:fix/rpc-request-owner-56

Conversation

@nick20002005

@nick20002005 nick20002005 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Refs #56

#56 の PR (a) です。最初のコミットで #66 と共通の要求・応答の形を定め、9/25・9/26 のレビューを反映して形は確定しました(c301361 / 2c91931 / 22e5e8b)。f20187e でホスト側の照合・記録・回収・復元と、クライアントの採番・未解決要求・Shutdown の宛先を入れ、(a) はこの範囲で完成です(9/28 のコメントで範囲を確定)。(b) は (a) のマージ後に main を土台とした別の PR として出します。

main 4399279(0.11.9)に載せています(その後の main は docs だけの変更です)。

形(確定)

形 設計スレッドとの対応
HostId(u128) / TsfId(u128) / EngineGen { host_id, generation: u64 } / Owner { tsf_id, composition: u64 } 9/22 §0 の識別子の表
RequestSeq = u64、Unresolved { seq, kind: ChangeKind, owner, engine_gen } 9/21 §1、9/22 §1
Request::Change { seq, expect: Expect { engine_gen, owner }, request: ChangeRequest, config_version } → Response::Changed { outcome: ChangeOutcome } 9/23 の 3、9/25 レビューの 1・A
ChangeRequest::kind()(ChangeRequest から ChangeKind への対応はこの 1 関数だけ) 9/25 レビューの 1
ChangeOutcome = Unit | Bool | Candidates(Vec<String>) | InputChar { preedit, hiragana, bg_status } 9/25 レビューの A
Request::Restore { owner, seq, reading, pending_romaji, then: Option<ChangeRequest>, config_version } → Response::Restored { engine_gen, then: Option<ChangeOutcome> } 9/22 §2・§5、9/25 レビューの A、9/26 レビュー
Response::Rejected(Reason)、Reason = GenMismatch | OwnerMismatch | ResultUnavailable 9/23 の 3、9/21
Request::Shutdown { expected_host_id } → ShutdownAccepted / ShutdownSkipped 9/22 §4
ShutdownIfConfigDiffers { config_json, expected_host_id, config_version }。host_id が違えば ShutdownSkipped 9/22 §4、9/25 レビューの B
Create { config_json, config_version }、config_version: Option<[u8; 32]>(#66 まで常に None) 9/25 レビューの C
Hello { protocol_version, tsf_id, highest_sent } → Hello { protocol_version, host_id, engine_gen: Option<EngineGen>, record_found } 9/21 §2
RequestRecord は server.rs の内部型 9/22 §2、9/25 レビューの 3
PROTOCOL_VERSION 5 → 6 —

挙動(f20187e)

ホスト

  • 起動時に host_id を乱数で採ります。エンジンを作るたびに世代 (host_id, 作成回数) を振り、新しいエンジンは所有者なしで始まります
  • tsf_id ごとに記録を 1 件持ちます。Hello で登録して、記録が無ければ floor = highest_sent で作ります。回収の対象は sessions == 0 && in_flight == 0 の記録だけで、256 件を超えたら古い順に捨てます。回収できる記録が無いときは上限を超えて受け付け、WARN を 1 回出します
  • Change は 1 回のエンジンロックの中で「世代 → 番号 → 所有者」の順に判定します(ロックの順序は state → 記録表)。同じ番号の再送には保持している応答を返します
  • Restore は同じロックの中で、reset_preedit → force_preedit → push_char × k → Done の変換器の回収 → 所有権の移動 → then の適用を行います
  • BG 変換を開始した所有者を記録し、候補はその所有者にだけ渡します
  • Shutdown 系は host_id を照合します(エンジンのロックは取りません)。handle_session が終了するかは応答で決めます

クライアント

  • tsf_id・採番・Unresolved は、プロセスで 1 つの台帳に置きます
  • change() / restore() / abandon_unresolved() を追加しました。再送は同じ番号で行い、通信が最終的に失敗した要求は結果不明として残して、次の変更要求を止めます
  • shutdown() は送る前に宛先の host_id を確定し、再接続した後の再送でも差し替えません。ShutdownOutcome::Skipped を追加し、shutdown_if_config_differs() は ConditionalShutdown { SameConfig, Restarting, Skipped } を返します

TSF: Skipped は ApplyOutcome::HostReplaced にしました。反映待ちは解除せず、無条件の Shutdown にも切り替えません。#65 の再接続の組込みは fukuyori さん側で行います。

まだ入れていないもの

  • TSF の送信を Change へ移すことと、旧 variant の _Reserved 化: 9/28 の合意(案 1)で (b) へ移しました。(a) では TSF が旧 variant を送り、ホストはそれを照合せずに処理します(挙動は従来どおり)。利用者向けの ホストが再起動した後、未確定文字が残っていると Space も Backspace も効かず、Esc で消すしかなくなる #56 の修正は (a)+(b) で完成し、リリースは両方が揃ってから 1 回です
  • (b) の受入条件として合意したもの: 新しい composition の最初の変更要求は必ず Restore { ..., then } で送り、所有権を得る前に Change を送らない/その Restore が返す世代を以後の Change.expect.engine_gen に使う/旧所有者の BG 変換が実行中の場合の応答型と TSF の遷移は、(b) の実装前に示す
  • Reload の variant と処理は触っていません。読み取り RPC は (b) で扱います

確認したこと

  • cargo fmt --all -- --check
  • cargo test -p rakukan-engine-rpc(57 件)/ cargo test -p rakukan-tsf(193 件)
  • cargo clippy -p rakukan-engine-rpc -p rakukan-tsf --all-targets(警告なし)
  • cargo check -p rakukan-engine-host
  • 実機では試していません。DLL を呼ぶ部分(apply_change / Restore の中身)は単体テストで通していません

🤖 Generated with Claude Code

Add host, engine, and composition identifiers, sequenced change and
restore request shapes, the shared Rejected(Reason) response, and
host-targeted shutdown responses. Bump the RPC protocol to v6.

Behavior is deferred to the rest of #56a: the host answers Change /
Restore with Error until matching, records, and restore are wired.

Refs fukuyori#56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fukuyori

Copy link
Copy Markdown
Owner

Draft PR をありがとうございます。形を設計スレッド(9/21〜9/23)と突き合わせました。識別子(HostId / TsfId / EngineGen / Owner)、Unresolved、Restore { …, then }、Rejected(Reason)、Shutdown の 2 応答、Hello の追加項目、v6 への版上げは記録どおりで、Reload と #65 の ShutdownOutcome の区別も保たれています。CI の成功は「形がビルドとシリアライズの往復を通る」ことの確認として受け取っています(復旧動作の確認ではない、という PR 本文の位置づけどおりです)。

確認の 5 点への回答

  1. Change { seq, expect, request } に包む形で進めてください。 照合と記録が 1 か所になり、Restore.then に同じ ChangeRequest 型を使え、ホスト再接続時の古い設定の再送による巻き戻りを防止する #66 の項目を入れ物の側に足せます。Change へ移行した変更系の旧 variant(InputChar / PushChar / Backspace / Commit など。読み取り RPC と Reload は対象外)は、(a) の中で TSF の送信を Change へ移した後、削除せず _Reserved… に置き換えてホストは Error を返す形にしてください(v4 の MergeCandidates と同じ扱い)。postcard の判別子は variant の並び順なので、削除すると後続の番号がずれます。あわせて ChangeKind と ChangeRequest の対応は ChangeRequest::kind() のような 1 関数に寄せ、追加時のずれを防いでください。
  2. 型の幅(識別子 u128、世代・連番・floor は u64、Hello 応答の engine_gen は Option)は、そのままで構いません。
  3. RequestRecord は server.rs へ移してください。 線路に流れないホスト内部の状態なので、Serialize / Deserialize も lib.rs からの再公開も不要です。
  4. config_version を Expect とは別のフィールドにする方針は 9/23 のとおりで賛成です。 ただし、後から足すのではなく今回のコミットで足してください(下の追加 3 点の C)。
  5. マージとリリースの順序: 0.11.9 は 9/24 に公開済みなので、v6 は次の版になります。(a) の完成後にマージし、(b) も入った時点で 1 回だけリリースする予定です。利用者に見える ホストが再起動した後、未確定文字が残っていると Space も Backspace も効かず、Esc で消すしかなくなる #56 の修正は (a)+(b) で揃い、プロトコルの版上げを伴うリリースを 2 回に分けなくて済みます。古いホストの残存については、開発用の install.ps1 はホストを停止しますが、配布インストーラー(rakukan_installer.iss、CloseApplications=no)にはホストを停止する処理が無いので、「通常は起きない」とは言えません。版上げを含むリリースの前に、こちらで扱いを決めます(インストーラー側で停止するか、版不一致の Hello を受けた TSF が旧ホストの終了を試みるか)。

型を確定する段階で決めたい追加の 3 点

A. Restore の then の結果を返す型。 9/22 §5 の設計では、新しい composition の最初の打鍵を Restore { reading: "", pending_romaji: "", then: InputChar } として送り、「ふだんの打鍵の RPC 回数は増えない」としています。現在の応答は Restored { engine_gen } だけなので、then の InputChar が返すべき候補・表示文字列・BG 状態を受け取る手段が無く、別 RPC を足すとこの前提が崩れます。変更要求の結果を表す enum(例 ChangeOutcome。InputChar の結果、候補列、Unit など既存の応答を写す)を定め、Response::Restored { engine_gen, then: Option<ChangeOutcome> } としてください。Change の応答も同じ型(例 Response::Changed { outcome })で返すと、(b) で足す編集状態を 1 か所に載せられます。

B. ShutdownIfConfigDiffers の host_id 不一致は ShutdownSkipped を返す。 ここは記録に食い違いがありました。9/22 §4 では「違えば Bool(false)」で、PR もそれに従っています。一方、こちらの計画書の #66 の受入条件は「host_id 不一致の再送が ShutdownSkipped になること」を根拠にしており、9/24 の着手承認ではどちらとも決めていませんでした。こちらの判断として ShutdownSkipped に揃えます。理由は、TSF 側(#65 の反映待ちの解除)が「同じ設定」と「別のホストだった」を区別でき、反映待ちを残して新しいホストへ Create し直せることです。Bool(true) / Bool(false) の意味は変えません。TSF 側で ShutdownSkipped を扱う変更は、#65 の再接続の組込みとしてこちらで行います。

C. config_version のフィールドを今回のコミットで足す。 struct variant へのフィールド追加は線路の形が変わるので、共通の形を確定するこのコミットで決めておきたい項目です。フィールドを今確定することと、#66 の照合を有効にするときにプロトコルの版を上げるかは別の判断です(#56 のホストが config_version を照合しないまま #66 のクライアントと同じ版で繋がると、古い設定を受理し得るため、そのときに改めて決めます)。Create / ShutdownIfConfigDiffers / Change に config_version: Option<[u8; 32]>(config.toml 本文の SHA-256。TSF 側 ConfigSnapshot の sha256 と同じ 32 バイト)を None 固定で足し、v6 の形として確定してください。None は「設定本文が無い(既定値由来)」を表すもので、#66 の実装では**「一致するハッシュ」として受理しない**条件にします(この条件は #66 側で明記します)。Reason の #66 用 variant(ConfigStale / ConfigUnavailable)は variant の後置で判別子を壊さないので、9/24 のとおり #66 で足す形で構いません。

上記 A〜C を反映した時点で形を確定とし、(a) の残りに進んでください。

- Add ChangeOutcome and return it from Restored { then } and the new
  Changed { outcome }, so Restore's `then` needs no extra RPC.
- Add ChangeRequest::kind() as the single ChangeRequest -> ChangeKind map.
- Add config_version: Option<[u8; 32]> (always None until fukuyori#66) to
  Create, ShutdownIfConfigDiffers and Change.
- Document that a host_id mismatch on ShutdownIfConfigDiffers answers
  ShutdownSkipped instead of Bool(false).
- Move RequestRecord into server.rs as host-internal state (no serde,
  no re-export).

Refs fukuyori#56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nick20002005

Copy link
Copy Markdown
Contributor Author

レビューありがとうございます。A〜C と、確認 5 点のうち今回の形に関わる分を 2c91931 で反映しました(本文の表も更新しています)。

  • 1: Change { seq, expect, request } のまま進めます。ChangeRequest::kind() を追加し、ChangeRequest → ChangeKind の対応はこの 1 関数だけにしました(網羅的な match なので、variant を足すとここでコンパイルが止まります)。旧 variant の _Reserved… 化は、TSF の送信を Change へ移すコミットで行います
  • 3: RequestRecord を server.rs の pub(crate) 型へ移し、Serialize / Deserialize と lib.rs の再公開を外しました
  • A: ChangeOutcome = Unit | Bool(bool) | Candidates(Vec<String>) | InputChar { preedit, hiragana, bg_status } を追加しました。旧 variant の応答(Unit / Bool / BgTakeCandidates の Strings / InputCharResult)をそのまま写しています。Response::Restored { engine_gen, then: Option<ChangeOutcome> } と Response::Changed { outcome } の両方でこの型を返します
  • B: ShutdownIfConfigDiffers の host_id 不一致は ShutdownSkipped と doc に明記し、サーバー側の TODO(#56a) もそれに合わせました(照合そのものは (a) の残りで入れます)。Bool(true) / Bool(false) の意味は変えていません
  • C: config_version: Option<[u8; 32]> を Create / ShutdownIfConfigDiffers / Change に追加し、送信側は None 固定です。型名は ConfigVersion にしています。doc には「None は設定本文が無いことを表し、照合は ホスト再接続時の古い設定の再送による巻き戻りを防止する #66 で有効にする」とだけ書きました。None を一致扱いしない条件は ホスト再接続時の古い設定の再送による巻き戻りを防止する #66 側でお願いします

cargo fmt --all -- --check、cargo test -p rakukan-engine-rpc(43 件)、cargo clippy -p rakukan-engine-rpc --all-targets(警告なし)、cargo check -p rakukan-tsf -p rakukan-engine-host を確認済みです。形はこれで確定として、(a) の残り(ホストの照合・記録・回収・復元、TSF の送信の Change への移行)に進みます。

@fukuyori

Copy link
Copy Markdown
Owner

2c91931 で A〜C の反映を確認しました。形の確定前に、もう一点お願いします。Restore は読みの復元に加え、then で変更要求を適用できるため、Restore にも config_version: Option<ConfigVersion> を追加してください。#56 の送信値は他の要求と同じく None 固定で構いません。

既存の codec.rs の Request::Restore 往復テストで、config_version: Some(..) も確認してください。#66 では復元と then の適用前に照合し、拒否時はどちらも適用しない設計とします。v6 はまだリリースしていないため、この追加による再度の版上げは不要です。

Restore can apply a change request through `then`, so it carries the
same config version as Create / ShutdownIfConfigDiffers / Change.
Always `None` until fukuyori#66, which checks it before restoring and applying
`then` and applies neither on rejection. v6 is unreleased, so no
further protocol version bump.

Refs fukuyori#56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nick20002005

Copy link
Copy Markdown
Contributor Author

22e5e8b で Restore に config_version: Option<ConfigVersion> を追加しました(送信値は他の要求と同じく None 固定です)。doc には「#66 まで常に None。照合は復元と then の適用より前に行い、拒否時はどちらも適用しない」と書いています。

codec.rs の Request::Restore 往復テストは config_version: Some([5; 32]) を送り、デコード後の値が一致することまで確認するように変えました。v6 は未リリースなので版は上げていません。

cargo fmt --all -- --check、cargo test -p rakukan-engine-rpc(43 件)、cargo clippy -p rakukan-engine-rpc --all-targets(警告なし)、cargo check -p rakukan-tsf -p rakukan-engine-host を確認済みです。本文の表も更新しました。

@fukuyori

Copy link
Copy Markdown
Owner

22e5e8b を確認しました。Restore への config_version の追加、Some([5; 32]) を使った往復テスト、復元と then の適用前に照合する条件の doc への記載は、いずれも依頼どおりです。これで要求・応答の形を確定とし、PR #67 で (a) の残りを進めてください。

最新の Windows CI は、既存のモデル変換テストが Hugging Face キャッシュの .lock を取得できずに失敗しています。この CI はこちらで対応するので、今回の作業では対応不要です。マージの判断は、(a) の実装完了と CI の成功を確認してから別途行います。

ShutdownSkipped については、(a) では次の範囲までお願いします。

  • ホスト側で expected_host_id を照合し、ShutdownAccepted / ShutdownSkipped の応答を生成する
  • クライアントで直近の Hello の host_id を保持し、expected_host_id に載せる(現在の TODO(#56a))。値は送る前に確定し、再接続した後の再送でも差し替えない(9/22 §4 のとおり、新しいホストを終了させないため)
  • クライアントの戻り値で Skipped を区別する。ShutdownOutcome に Skipped を追加し、shutdown_if_config_differs() は「同じ設定 / 再起動を受理 / Skipped」の 3 値を返す形にする。Skipped を Err にすると、呼び出し元が無条件の shutdown() に切り替えてしまうため

戻り値の変更に伴う state.rs の呼び出し箇所は、(a) ではビルドを通すための最小限の対応にとどめ、Skipped は反映待ちを解除しない(成功として扱わない)形にしてください。反映待ちを残し、現在の設定から新しいホストへ Create し直す #65 の再接続の組込みは、予定どおりこちらで行います。

Host:
- Take a random host_id at startup and give each engine a generation
  (host_id, count) on Create / Reload; new engines start with no owner.
- Keep one RequestRecord per tsf_id (last seq and reply, floor,
  sessions, in_flight, last_used). Hello registers the session, answers
  record_found, and creates missing records with floor = highest_sent.
  Records with no session and nothing in flight are collected oldest
  first above 256; otherwise the table grows and warns once.
- Change: generation, then seq (same seq replays the stored reply,
  older or <= floor is ResultUnavailable), then owner, all under the
  engine lock (lock order state -> records).
- Restore: seq check, then reset_preedit / force_preedit / push_char,
  reclaim the done converter, move ownership and apply `then` in the
  same lock.
- Track the owner of a started BG conversion and hand its candidates
  only to that owner.
- Shutdown / ShutdownIfConfigDiffers answer ShutdownSkipped for another
  host_id, and handle_session exits based on the reply.
- Legacy change variants share apply_change without matching.

Client:
- A process-wide ledger holds tsf_id, the seq counter and the single
  Unresolved request; Hello reports tsf_id and highest_sent.
- change() / restore() number requests, resend the same seq on retry,
  keep failures unresolved and refuse new changes until restore or
  abandon_unresolved().
- shutdown() fixes the target host_id before sending and returns
  Skipped; shutdown_if_config_differs() returns SameConfig /
  Restarting / Skipped.

TSF: Skipped maps to ApplyOutcome::HostReplaced, which keeps the pending
apply and does not fall back to an unconditional Shutdown. TSF senders
still use the legacy variants.

Refs fukuyori#56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nick20002005

Copy link
Copy Markdown
Contributor Author

形の確定をありがとうございます。(a) の残りのうちホスト側とクライアント側を f20187e に入れました。ShutdownSkipped はご指定の 3 点の範囲で実装しています。ただし TSF の送信を Change へ移す部分はまだ入れていません。この部分の範囲を確認させてください(下の「確認したいこと」)。

f20187e で入れたもの

ホスト

  • 起動時に host_id を乱数で採ります。Create / Reload でエンジンを作るたびに世代 (host_id, 作成回数) を振り、新しいエンジンは所有者なしで始まります
  • tsf_id ごとに RequestRecord を 1 件持ちます。Hello で接続を登録して record_found を返し、記録が無ければ floor = highest_sent で作ります。回収の対象は sessions == 0 && in_flight == 0 の記録だけで、256 件を超えたら last_used の古い順に捨てます。回収できる記録が無いときは上限を超えて受け付け、WARN を 1 回だけ出します。in_flight は要求を読んでから dispatch を抜けるまで数えます
  • Change は 1 回のエンジンロックの中で「世代 → 番号 → 所有者」の順に判定します。同じ番号の再送には保持している応答を返し、記録より小さい番号と floor 以下の番号は ResultUnavailable にします。ロックの順序は state → 記録表です
  • Restore は同じロックの中で、番号を判定してから reset_preedit → force_preedit → push_char × k を行い、Done の変換器を回収したうえで所有権を移し、then を適用します
  • BG 変換を開始した所有者を記録し、候補はその所有者にだけ渡します
  • Shutdown / ShutdownIfConfigDiffers は、宛先の host_id が違えば ShutdownSkipped を返します。handle_session が終了するかは応答で決めます(ShutdownAccepted、または ShutdownIfConfigDiffers に Bool(true) を返したときだけ)
  • 旧 variant の変更要求は apply_change を共有しますが、照合はしません

クライアント

  • tsf_id・採番・Unresolved は、プロセスで 1 つの台帳(static)に置きました。RpcEngine を作り直しても消えません。Hello では tsf_id と highest_sent を送ります
  • change() / restore() を追加しました。再送は同じ番号で行い、通信が最終的に失敗した要求は結果不明として残します。残っている間、次の change() は ChangeError::Unresolved を返して送りません。解決するには restore() か abandon_unresolved() を使います
  • shutdown() は、送る前に直近の Hello の host_id で宛先を確定し、再接続した後の再送でも差し替えません。ShutdownOutcome::Skipped を追加しました。shutdown_if_config_differs() は ConditionalShutdown { SameConfig, Restarting, Skipped } を返します

TSF(最小限): Skipped は新しい ApplyOutcome::HostReplaced にしました。反映待ちは解除せず、無条件の Shutdown にも切り替えません。その後の流れ(ハンドルを捨てて再接続する)は従来のままで、#65 の組込みはお任せします。

スレッドで決めていなかったところ(こちらで決めた点)

  1. 所有者違いの拒否は、その番号の応答として記録します。 同じ番号の再送には同じ拒否を返し、それより古い番号も引き続き止まります。世代違いと ResultUnavailable は記録を変えません
  2. 所有者がいない(作ったばかりの)エンジンへの Change は OwnerMismatch にします。 所有権は Restore でしか移りません
  3. 同じ番号の再送で所有者が変わっていても、保持している応答をそのまま返します(9/18 の「所有者違いも併せて伝える」は、応答の形に欄が無いので入れていません)。所有者が変わったことは、次の Change で OwnerMismatch として分かります
  4. Restore が拒否されたとき・Error のときは、クライアントの未解決をそれまでの状態に戻します(then も適用されていないため)。通信が失敗したときは、then を結果不明として残します
  5. 採番の前に接続を済ませます。 まだ送っていない番号を highest_sent に含めると、新しい記録の floor がその要求自体を拒否してしまうためです
  6. Hello の engine_gen は Create の前の値です。 最初の接続や、Create でエンジンを作り直した後は古くなりえます。change() の期待値には Restored で得た世代を使う前提です
  7. ShutdownSkipped を受けたときは接続を残します(ホストは終了していないため)

確認したいこと: TSF の送信を Change へ移す範囲

移行すると、世代違い・所有者違いの拒否を受けた打鍵は適用されません。拒否を受けた後に TSF が復元できないと、打鍵が消えるので、今の「空のエンジンに積まれる」より悪くなります。復元するには、composition ごとに直近の編集状態(読みと未確定ローマ字)が要ります。今の応答で編集状態が返ってくるのは InputChar だけで、PushChar / PushRaw / Backspace / FlushPendingN の後は分かりません。9/25 の A で「ChangeOutcome に (b) で編集状態を足す」としたのは、ちょうどこの部分です。

そのため、次のどちらにするかを決めていただけますか。

  • 案 1(こちらの推奨): TSF の移行と旧 variant の _Reserved 化を (b) へ移す。 (a) はホスト・クライアントの仕組みと Shutdown 系までにして、旧 variant が生きたままマージします(ホストは両方を処理できます。旧 variant は照合しないので、挙動は今と同じです)。(b) で ChangeOutcome に編集状態を足し、復元元の保持・読み取りの Result 化・Change への移行を一緒に行います。リリースは (a)+(b) で 1 回という予定とも合います
  • 案 2: (a) の中で移行する。 その場合は (a) でも、ChangeOutcome の Unit / Bool を返す変更要求に編集状態を足すことになり、(b) の一部を前倒しすることになります。あわせて、復元元を持たない composition が拒否を受けたときの扱い(9/18 に「表示中の文字列を確定して composition を閉じる」を提案したまま、決まっていません)も決める必要があります

確認したこと

  • cargo fmt --all -- --check
  • cargo test -p rakukan-engine-rpc: 57 件成功。追加したのは次のとおりです
    • 記録表: 再送・floor・回収・上限
    • 判定の順序
    • 応答による終了判断
    • エンジンのロックを保持したままの Shutdown の照合
    • Hello の登録と in_flight
    • クライアント: 同じ番号での再送、未解決による送信停止、復元による解決、Shutdown の宛先を再接続後も変えないこと、Skipped の 3 値
  • cargo test -p rakukan-tsf: 193 件成功(config_apply_tests と comm_failure_keeps_pending に Skipped を追加)
  • cargo clippy -p rakukan-engine-rpc -p rakukan-tsf --all-targets: 警告なし
  • cargo check -p rakukan-engine-host
  • 実機では試していません。apply_change / Restore の中身(DLL を呼ぶ部分)は単体テストで通していません

@fukuyori

Copy link
Copy Markdown
Owner

f20187e と説明を確認しました。TSFの送信を Change へ移す範囲については、案1を採用します。TSFの Change 移行と旧変更系variantの _Reserved 化は (b) へ移してください。

現在の ChangeOutcome::Unit / Bool だけでは、拒否後の復元元となる編集状態を保持できません。(b) で編集状態を応答に追加し、compositionごとの復元元保持、composition依存の読み取りの Result 化、TSFの Change 移行を一緒に行う形とします。(a) の段階では旧variantを使い続け、利用者向けの #56 の修正は (a)+(b) で完成するものとします。リリースも予定どおり両方が揃ってから1回です。

「スレッドで決めていなかったところ」として挙げられた7点は、次の条件を含めて了承します。

  1. OwnerMismatch をその番号の応答として記録し、同じ番号の再送には同じ拒否を返す形で構いません。
  2. 所有者のいないエンジンへの Change を OwnerMismatch にする形で構いません。ただし、(b) でTSFを移行するときは、新しいcompositionの最初の変更要求を必ず Restore { ..., then } で送り、所有権を得る前に Change を送らないことを受入条件にしてください。
  3. 同じ番号の再送では、現在の所有者が変わっていても保持済みの応答をそのまま返す形を了承します。9/18の「所有者違いも併せて伝える」はこの再送応答には載せず、次の新しい Change で OwnerMismatch を検出する整理とします。再送による二重適用を防ぐことを優先します。
  4. Restore が拒否または Error になった場合は以前の未解決要求へ戻し、通信失敗では then を結果不明として残す形で構いません。
  5. まだ送っていない番号が highest_sent に入り、新しい記録の floor でその要求自体が拒否されることを防ぐため、接続を済ませてから採番する順序で構いません。
  6. Hello.engine_gen が Create 前の値であることを了承します。(b) では、新しいcompositionの最初の Restore が返す世代を、その後の Change.expect.engine_gen に使ってください。
  7. ShutdownSkipped では対象のホストが終了していないため、接続を保持する形で構いません。

旧所有者のBG変換が実行中の場合の扱いは、composition依存の BgStatus の照合・Result 化とともに (b) で確定してください。現在の bg_reclaim() は非ブロッキングで、実行中の旧変換を待たないため、通常の "running"、OwnerMismatch、旧所有者のワーカー使用中を区別できる専用の結果と、TSF側の遷移が必要です。具体的な応答型と遷移を、(b) の実装前に示してください。

ShutdownSkipped の最小対応は依頼した範囲どおりです。反映待ちから現在の設定を使って新しいホストへ Create し直す #65 の組込みはこちらで行います。

CIの成功は確認しました。一方、apply_change / Restore のDLL呼び出し部分と実機での復旧動作は未確認なので、(b) の統合後に別途検証します。今回の回答は (a)/(b) の範囲と設計判断への回答であり、マージの承認ではありません。

(a) はこの範囲で完成とし、Draft を外してレビュー待ちにしてください。(b) は (a) のマージ後に、main を土台とした別の PR として始めてください(9/24・9/25 のとおり)。

@nick20002005 nick20002005 changed the title feat(rpc): #56 の要求・応答の形を定める(PR (a) の最初のコミット) feat(rpc): #56 の要求・応答の形と、ホスト・クライアント側の照合(PR (a)) Sep 29, 2026
@nick20002005
nick20002005 marked this pull request as ready for review September 29, 2026 02:39
@nick20002005

Copy link
Copy Markdown
Contributor Author

範囲の確定と 7 点のご確認をありがとうございます。案 1 で進めます。Draft を外してレビュー待ちにしました。本文は、「まだ入れていないもの」を合意した内容に書き換え、(b) の受入条件を書き足しています。

(b) は (a) のマージ後に、main を土台とした別の PR として始めます。着手の前に、旧所有者の BG 変換が実行中の場合の応答型(通常の running/OwnerMismatch/旧所有者のワーカー使用中を区別する専用の結果)と TSF 側の遷移を、このスレッドか #56 でお示しします。

@fukuyori

Copy link
Copy Markdown
Owner

f20187e を確認しました。(a) の実装範囲はおおむね合意どおりですが、マージ前に次の点を修正してください。

1. ResultUnavailable では未解決要求を消さない

send_change は、応答を受信すると種類を問わず ledger.resolve(seq) を呼んでいます(client.rs:1029-1035)。そのため、Rejected(Reason::ResultUnavailable) の場合も未解決要求が消えます。

ResultUnavailable は、その要求が適用されたかどうかホスト側で判断できないという応答です。9/22 の設計どおり、結果不明の状態を残してください。

具体的には、Changed や、再送しても適用されないことが確定する拒否(GenMismatch / OwnerMismatch)とは分け、ResultUnavailable の場合は ledger.resolve(seq) を呼ばないようにしてください。その後の変更要求が ChangeError::Unresolved になることまでテストしてください。

2. Restore の ResultUnavailable では prev に戻さない

send_restore は、拒否理由を問わず以前の未解決要求 prev へ戻しています(client.rs:1075-1079)。

しかし、ResultUnavailable の場合は、Restore と then がすでに適用された可能性があります。そのため、次の扱いにしてください。

  • then がある場合は、その then を結果不明として残す
  • then がない場合は、それまでの未解決要求をそのまま残す
  • ResultUnavailable 以外の拒否と Error の場合は prev へ戻す

現在の begin_restore は、then があればすでにそれを台帳へ記録し、なければ以前の未解決要求を維持しています。したがって、ResultUnavailable の場合に set_unresolved(prev) を行わなければ、この区別を保てます。

これは、9/28 に了承した「Restore が拒否または Error なら以前の未解決要求へ戻す」という扱いに対し、ResultUnavailable は例外であるという補足です。

既存の restore_resolves_the_pending_request_and_learns_the_generation テストは、ResultUnavailable のあとに prev へ戻ることを明示的に検証しています(client.rs:2130-2135)。この期待値を、then が未解決として残ることの検証へ変更してください。あわせて、then がない場合に以前の未解決要求が残るケースも確認してください。

3. 保存済みの Restore 応答を、エンジン状態より先に確認する

dispatch_restore は、保存済み応答を調べる前に state.engine と state.engine_gen の有無を確認しています(server.rs:888-896)。

この順序では、次のような場合に同じ番号の再送へ同じ応答を返せません。

  1. Restore が適用され、Restored が記録される
  2. 応答だけが失われる
  3. 別セッションの Reload などでエンジンが外れ、再ロードに失敗する
  4. 同じ Restore が再送される
  5. 保存済みの Restored ではなく Error("engine not created") が返る

現在の TSF から Reload は呼ばれていないため、起きる可能性は低い経路ですが、同じ番号の再送では保存済みの応答を返すという合意と関数の説明に合わせてください。

エンジンの有無を判定する前に records.check(tsf, seq) を行い、Replay と Unavailable を処理したあと、新しい要求の場合だけエンジンの存在を要求する形がよいと考えます。

保存済みの Restore 応答が、エンジンのない状態でも再送されることのテストも追加してください。

4. dispatch_restore の BG 変換に関する説明を実装に合わせる

server.rs:873-874 のコメント「別の所有者の変換が実行中なら止めずに終わるのを待ち、その結果は誰にも取り出させない」は、現在の実装と合っていない点があります。

  • eng.bg_reclaim() は非ブロッキングで、Done の変換器を回収するだけです。実行中の変換は待たずに bg_owner = None として Restored を返します。「待ち」がブロックして待つとも読めるので、実行中の変換は中断も待機もせず、所有者の記録だけを消すことが分かる記述にしてください
  • 「誰にも取り出させない」は Change 経由の取り出しに限られます。旧 variant の BgTakeCandidates は所有者を見ず、キーの一致だけで取り出せるので、(a) の段階ではその旨を併記してください

旧所有者のワーカー使用中の扱いは、合意どおり (b) で確定します。

engine_gen() について

Hello.engine_gen が Create 前の値になること自体は、9/28 の6点目で了承した仕様です。(b) では、新しい composition の最初の Restore が返した世代を、その後の Change.expect.engine_gen に使用してください。

可能であれば、公開関数 RpcEngine::engine_gen() の doc にも、Hello 由来の値は後続の Create より前の世代である可能性があり、最初の Change の期待値には使用しないことを追記してください。これは必須修正ではありません。

今回確認した範囲では、上記以外は (a) の合意範囲と一致していると考えます。修正後に再確認します。

… the engine check

Client:
- send_change no longer resolves the request on Rejected(ResultUnavailable);
  the next change fails with ChangeError::Unresolved.
- send_restore does not fall back to the previous unresolved request on
  Rejected(ResultUnavailable): `then` stays unresolved if present,
  otherwise the previous unresolved request stays. Other rejections and
  Error still restore `prev`.
- Document that the Hello-derived engine_gen() may predate a later Create
  and must not be the first Change's expectation.

Host:
- dispatch_restore checks the stored reply (Replay / Unavailable) before
  requiring an engine, so a resent Restore gets the stored Restored even
  if the engine was dropped in between.
- Describe the BG handling as it is: bg_reclaim does not wait, a running
  conversion of another owner keeps running and only the owner record is
  cleared; legacy BgTakeCandidates can still take its result by key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nick20002005

Copy link
Copy Markdown
Contributor Author

ご指摘ありがとうございます。4 点を 72a959f で修正しました。

  1. send_change は Rejected(ResultUnavailable) では resolve せず、結果不明として残します。次の変更要求が ChangeError::Unresolved になることをテストしました。
  2. send_restore は ResultUnavailable では prev へ戻しません(then があればそれが、無ければそれまでの未解決が残ります)。既存テストの期待値を then が残る検証へ変え、then なしの場合と、他の拒否・Error で prev へ戻る場合のテストも追加しました。
  3. dispatch_restore は records.check を先に行い、Replay / Unavailable を処理してから、新しい要求だけでエンジンの存在を要求します。エンジンが無い状態で保存済みの Restored が返るテストを追加しました。
  4. コメントを「bg_reclaim は待たず、実行中の変換は中断も待機もせず所有者の記録だけ消す」「旧 variant の BgTakeCandidates はキーの一致で取り出せる」に改めました。

engine_gen() の doc にも、Hello 由来の値は最初の Change の期待値に使わない旨を追記しています。

@fukuyori
fukuyori merged commit d94f34c into fukuyori:main Sep 30, 2026
2 checks passed
@fukuyori

Copy link
Copy Markdown
Owner

72a959f の修正を確認しました。依頼した4点と engine_gen() の doc はいずれも反映されており、追加・変更されたテストも意図した状態を確認しています。PR #67 上の Windows CI と Format Check の成功も確認しました。

PR #67 は rebase merge しました。main の先端は d94f34c8 です。これで (a) は完了です。対応ありがとうございました。

次は合意どおり、最新の main を土台に (b) を別PRとして進めてください。ただし、実装へ入る前に、旧所有者のBG変換が実行中の場合について、次の3つを区別する具体的な応答型とTSF側の遷移を、このスレッドか #56 に示してください。

  • 現在の所有者自身の通常の running
  • OwnerMismatch
  • 旧所有者の変換ワーカーがまだ使用中である状態

(b) では、これまでの合意どおり次をまとめて扱ってください。

  • ChangeOutcome への編集状態の追加
  • compositionごとの復元元の保持
  • composition依存の読み取りの Result 化
  • 読み取りの失敗を unwrap_or_default() などで空の値へ戻さず、TSF側の遷移まで区別すること
  • 新しいcompositionの最初の変更要求を Restore { ..., then } で送る処理
  • Restored が返した世代を、その後の Change.expect.engine_gen に使用する処理
  • TSFの送信を Change へ移行する処理
  • BgTakeCandidates を Change として採番し、応答を再送用に保持すること
  • 旧変更系variantの _Reserved 化

#65 の再接続組込みは、(b) と並行してリリース前までにこちらで行います。

配布インストーラー更新時の旧ホスト停止は、(a) と (b) のマージ後、v6 のリリース前に、現行の rakukan_installer.iss へ必要な処理を先行して実装します。全面的なインストーラー再設計は、予定どおり #33 の別作業として扱います。

リリースと実DLL・実機での統合確認は、(b)、#65 の再接続組込み、旧ホスト停止がすべて揃ってから行います。

@nick20002005

Copy link
Copy Markdown
Contributor Author

(a) のマージをありがとうございました。(b) の実装前に、旧所有者の BG 変換が実行中の場合の応答型と TSF 側の遷移を示します。main b77d7df のコードで確認しています。

1. 3 つの状態をどこで分けるか

所有権は Restore でだけ移り、Restore は bg_owner を消します。したがって、所有者が一致した要求元から見ると bg_owner は「自分」か「なし」の 2 通りです。ホストはエンジンのロックの中で次のように答えます。

owner の照合 bg_owner conv_cache 応答
不一致 — — Rejected(OwnerMismatch)
一致 自分 Idle / Running / Done / 失敗 BgView::Idle / Running / Done / Failed
一致 なし Running BgView::WorkerBusy
一致 なし Idle BgView::Idle

OwnerMismatch は (a) と同じ Rejected(Reason) に載せます。WorkerBusy は世代も所有者も合っていて拒否ではないので、Reason には足さず、成功側の値にします(変更要求の場合は Changed として記録・再送の対象になります)。

所有者のいない Done は、要求の入口で回収します。 Restore の時点で実行中だった旧所有者の変換は、あとで Done になります。これを残すと、新しい所有者からは「bg_owner なし・変換器はキャッシュの中(is_kanji_ready() が false)」に見え、TSF の Space 経路が「モデル未ロード」と取り違えます(on_convert.rs:1033 の !kanji_ready && bg_status == "idle")。inject_ready と同じ場所(エンジンロック内、要求を処理する前)で、bg_owner がなく Done なら bg_reclaim() します。何度行っても結果は同じです。これで上の表のとおり、bg_owner なしの状態は Idle か WorkerBusy だけになります。

2. 応答型

/// 所有者が一致した要求元から見た BG 変換の状態。
pub enum BgView {
    Idle,
    /// 自分が開始した変換が実行中
    Running,
    Done,
    /// 旧 "error"
    Failed,
    /// 自分は開始していないが、ワーカーが旧所有者の変換を実行中(変換器が戻っていない)
    WorkerBusy,
}

pub enum BgStartOutcome {
    Started,
    /// 同じ読みの自分の変換が Running / Done(旧 false のうち再起動不要のもの)
    AlreadyCurrent,
    WorkerBusy,
    /// 変換器が無い(モデル未ロード)・読みが空
    NotReady,
}
  • 読み取り BgStatus { expect } → Response::Bg(BgView) / Rejected(GenMismatch | OwnerMismatch)
  • ChangeRequest::BgStart → ChangeOutcome::BgStart(BgStartOutcome)(Bool をやめる)。WorkerBusy のときエンジンは何も変えません(現行でも engine.kanji が無く bg_start は false で戻ります)
  • ChangeOutcome::InputChar の bg_status: String → bg: BgView
  • BgPeekTopCandidate { expect, key } は、bg_owner が自分でなければ None(WorkerBusy を含む)
  • BgTakeCandidates は (a) のまま(bg_owner が自分でなければ空)
  • BgWaitMs { expect, timeout_ms } → 待った後の BgView を返します。入口で WorkerBusy なら待たずにそのまま返します(キー処理の中で他人の変換の完了を待たないため)

クライアントは Result<BgView, ReadError>(通信失敗 / GenMismatch / OwnerMismatch)で返し、文字列には戻しません。

3. TSF 側の遷移

経路 自分の Running OwnerMismatch WorkerBusy
Space(on_convert) 現行どおり(Waiting に入りタイマーで拾う) 9/22 の手順(Restore { …, then }) モデル待ちと同じ経路に入る(下記)
待機タイマー(on_waiting_timer) 次の tick を待つ タイマーを止め、表示と復元元を保持。復元後は Preedit 次の tick を待つ(モデル待ちの中でだけ起こる)
ライブ変換タイマー 次の tick を待つ タイマーを止め、表示と復元元を保持 bg_start を呼ばず次の tick を待つ。preview は辞書由来だけで作る
確定時の追いつき(on_convert.rs:2168) 現行どおり待つ 確定は止めない(9/22 の Commit の扱い) 待たずに現在の preview で確定(現行の bg_start refused と同じ)

Space で WorkerBusy の場合は、既存のモデル待ち(start_model_wait / tick_model_wait、candidate_window.rs:1322)と同じ形にします。

  1. 辞書候補があれば Selecting で即表示し、待機タイマーを始める(候補送り・確定は通常どおり効く)
  2. tick で BgStatus を読み、WorkerBusy の間は待つ
  3. Idle になり、読みが変わっていなければ BgStart を 1 回送り、llm_pending = true にする。以降は現行の完了の拾い方と同じ
  4. 上限(MODEL_WAIT_LIMIT_MS)を超えたら打ち切り、辞書候補のまま続ける

タイマーが送るのは BgStart だけで、所有権は取りに行きません。この BgStart や BgStatus が OwnerMismatch で返った場合は、合意どおりタイマーを止め、表示と復元元を保持します。

WorkerBusy が続く長さは旧所有者の変換 1 回ぶんで、詰まった場合は #59 の監視(30 秒でホストが自己終了)が上限になります。

4. テスト

  • ホスト: Restore の後、旧所有者の変換が Running の間は BgStatus / BgStart が WorkerBusy、終わった後の最初の要求で Done が回収され Idle になり、BgStart が Started になること。旧所有者の結果が BgTakeCandidates / BgPeekTopCandidate で取り出せないこと
  • TSF: WorkerBusy を「モデル未ロード」「自分の Running」のどちらの分岐にも落とさないこと(BgView の match を網羅にして、文字列比較を残さない)

変換ワーカーの Running は実モデルが無いと作れないので、ホスト側のテストは conv_cache の状態を差し替えられる形(判定関数を bg_owner と状態値の純粋関数に分ける)で書きます。

この形でよければ (b) の実装に入ります。

@fukuyori

fukuyori commented Oct 1, 2026

Copy link
Copy Markdown
Owner

応答型と TSF 遷移の案をありがとうございます。main b77d7df のコードと照合しました。WorkerBusy を拒否ではなく成功側の値にすること、OwnerMismatch を (a) と同じ Rejected(Reason) に載せること、BgView を網羅的な match で扱って文字列比較を残さないこと、Space の WorkerBusy を既存のモデル待ちの形に寄せることには賛成です。

ただし、現在のコードと照らすと次の点が未定義または食い違っているので、実装に入る前に形を補ってください。

1. WorkerBusy の判定に pending を含める

conv_cache::start() は要求を pending に積むだけで、状態は Idle のままです。ワーカーが取り出して初めて Running になります(conv_cache.rs:273-345、182-184 付近)。status() は state だけを見ています(conv_cache.rs:554)。

そのため、旧所有者の要求がキューに積まれ、ワーカーが拾う前に Restore されると、新しい所有者からは「bg_owner なし・Idle」に見えます。表ではこれが BgView::Idle になりますが、実際には変換器は pending にあって engine.kanji は空です。現行の bg_start はこの状態で false を返し(lib.rs:1305 付近)、Space は !kanji_ready && idle(on_convert.rs:1033)でモデル未ロードと取り違えます。Done の回収で避けようとしている問題と同じです。新しい BgStartOutcome でも、この状態を NotReady に分類しないようにしてください。

WorkerBusy は Running だけでなく、「bg_owner なし、かつ変換器が pending か Running にある」で判定してください(holds_converter() / has_converter() は pending を含めて判定しています)。所有者のいない Done が残った場合の扱いは、下の 2 で別に定義してください。

2. 入口での Done の回収が失敗した場合の応答

bg_reclaim() が使う try_reclaim_done() は try_lock() で、ロックが取れなければ何も回収しません(conv_cache.rs:412)。このため、表で起こらないとしている「bg_owner なし・Done(failed を含む)」が残りえます。

回収できたかを判定してから状態を返し、回収できなかった場合の応答を定義してください。この Done は変換の実行中ではないので、WorkerBusy に含める場合は、その意味を「変換器が旧所有者側に残っている」まで広げることを型の説明に明記してください。別の値にする場合は、TSF がそれをどう扱うか(次の要求で回収し直す等)も含めて決めてください。

3. BgStartOutcome の抜けと AlreadyCurrent の意味

  • 自分の変換が別の読みで実行中(または pending)の場合が、Started / AlreadyCurrent / WorkerBusy / NotReady のどれにも当たりません。現行では engine.kanji が空なので bg_start は false で戻ります。ライブ変換で読みが伸びたときに通る経路と見込んでいます(コードを読んでの推測で、頻度は確かめていません)。値と、TSF がどう遷移するか(完了を待って同じ読みで再開始するか等)を決めてください
  • **AlreadyCurrent の「同じ読みの自分の Done」**は、現行の bg_start と一致しません。bg_start は最初にキーを問わず try_reclaim_done() で Done を回収してから開始する(lib.rs:1289-1295)ので、同じ読みの Done も今は変換し直されます(conv_cache::start の同一キーの判定、conv_cache.rs:293 付近には届きません)。AlreadyCurrent を「既存の結果を使う」とするなら回収の前に判定する変更が要ります。「開始を抑止した」だけの意味にするのかを含めて、Done の回収の前後で定義してください

4. Space の WorkerBusy は独立した分岐にする

案の 3 で示された手順には賛成です。実装時の確認として、現行の on_convert は「モデル未準備かつ idle」で start_model_wait に入り(on_convert.rs:1033)、bg=running では前の変換の完了を待って回収する別経路に入ります(on_convert.rs:1313 付近の prev_bg_running_wait)。WorkerBusy がこのどちらかに落ちると、旧所有者の変換をキー処理の中で待つことになるので、idle / モデル未準備、自分の Running のいずれとも別の分岐として実装することを (b) の受入条件に入れてください。

5. 読み取りの失敗を、BG の取り出しを含めて区別する

BgStatus の Result<BgView, ReadError> は妥当です。同じ扱いを次にも揃えてください。

  • BgPeekTopCandidate: ここでの「所有者違い」は 2 種類あるので分けてください。要求元の owner / engine_gen が現在と違う場合(Rejected(OwnerMismatch | GenMismatch))と、要求元は正しいが BG の結果が要求元のものではない場合(案の「bg_owner が自分でなければ None」)です。後者を None にする案はそのままで構いません。ただし現行のクライアントは通信失敗と空文字列も None にまとめている(client.rs:741-746)ので、通信失敗と前者の拒否は None に潰さずエラーとして返し、TSF がその None を「候補なし」やエラーと混同しない扱いを決めてください。ライブ preview や確定時の追いつきが、失敗を通常の候補不足として進むのを防ぐためです
  • BgTakeCandidates: Change 経由では、BG の結果が要求元のものでない場合を空の候補で返しています(server.rs:988-993)。(b) では「結果はあるが候補が空」と「要求元の結果ではない・まだ取り出せない」を区別してください
  • TSF 側で、エンジンの取得失敗を "idle" とみなしている箇所(例: candidate_window.rs:1496-1499)も、9/22 の受入条件(失敗を空値や既定値に戻さない)の対象に含めてください

6. WorkerBusy が続く場合の上限を TSF 側に持つ

案では上限を #59 の監視(30 秒でホストが自己終了)としていますが、次の 2 点で常には成り立ちません。

  • 監視の対象は State::Running だけで、Idle && pending=Some のまま拾われない詰まりは検出できないと health.rs の冒頭に明記されています。1 の判定で pending を WorkerBusy に含めると、この状態は監視の上限を持ちません
  • 詰まりの検出でホストが終了するのは段階 1 で、段階 2(unrecoverable)ではホストは生き続けます(health.rs 冒頭の「段階」)

Space は MODEL_WAIT_LIMIT_MS(10 秒、candidate_window.rs:1118)で打ち切られるのでよいと考えます。ライブ変換タイマーと待機タイマーの「次の tick を待つ」にも、TSF 側の上限と、上限に達したときの遷移(辞書候補のまま続ける等)を決めてください。

7. その他

  • 旧 variant の BgTakeCandidates: (a) では所有者を見ずキーの一致で取り出せます(server.rs の legacy_change → apply_change に bg = None)。(b) で旧 variant を _Reserved にすれば解消しますが、TSF の送信を Change へ移す途中で旧経路が残らない順序にしてください
  • ライブ変換の WorkerBusy 時の preview: 「辞書由来だけで作る」は、既存の immediate dict preview while bg=running(candidate_window.rs:2152 付近)の経路を使う想定かを明記してください
  • テスト: 案の 4 に加えて、pending の状態での WorkerBusy、入口の回収が失敗した場合の応答、同じ所有者が Restore し直したときに自分の実行中の変換も WorkerBusy として扱われること(Restore が bg_owner を消すため。意図した挙動として)を入れてください

上記を反映した応答型と遷移を示していただければ、照合してから (b) の実装着手を判断します。今回の確認はコードを読んでの照合で、実機では確かめていません。

@nick20002005

Copy link
Copy Markdown
Contributor Author

ご確認ありがとうございます。7 点とも、main b77d7df のコードで指摘のとおりであることを確かめました。補った形を示します。

1・2. 判定の材料を「変換器の位置」に替える

status() は state しか見ないので、pending を含めた位置を 1 回のロックで返す読み取りを、エンジン DLL に 1 つ足します(ABI の追加です)。

/// 変換キャッシュの中の変換器の位置。`same_reading` はキーがエンジンの現在の読みと一致するか。
pub enum BgSlot {
    /// pending にも state にも無い(`holds_converter() == false`)
    Empty,
    /// pending に積まれ、ワーカーがまだ拾っていない(pending があればこれを優先)
    Queued { same_reading: bool },
    Running { same_reading: bool },
    Done { same_reading: bool, failed: bool },
}

ロックは take_ready と同じ理由で blocking にします。poison は値に倒さず読み取りの失敗として返し、ホストは "idle" に丸めません。

ホストは要求の入口(エンジンロック内)で、bg_owner がなければ bg_reclaim() を試み、その後で BgSlot を読んで答えます。

owner の照合 bg_owner 回収を試みた後の BgSlot 応答
不一致 — — Rejected(OwnerMismatch)
一致 自分 Empty BgView::Idle
一致 自分 Queued / Running BgView::Running
一致 自分 Done / Done { failed } BgView::Done / Failed
一致 なし Empty BgView::Idle
一致 なし Queued / Running / Done BgView::WorkerBusy
  • 1: WorkerBusy の条件は「bg_owner なし、かつ holds_converter()」になり、pending を含みます。bg_owner なしの Idle は、変換器がキャッシュのどこにも無い場合だけです
  • 2: 入口の回収が try_lock の失敗で空振りした Done(failed を含む)は、別の値を足さず WorkerBusy に含めます。型の説明は「変換器が旧所有者の側に残っている(開始待ち・実行中・未回収の完了)。変換の実行中とは限らない」に改めます。回収は要求の入口で毎回試みるので、TSF は次の tick で読み直すだけで、扱いは他の WorkerBusy と同じです(6 の上限が掛かります)
  • 自分の Queued は BgView::Running(説明は「自分の変換が開始待ちまたは実行中」)にします。BgStart の直後に Idle と読める隙間が無くなります

3. BgStartOutcome

pub enum BgStartOutcome {
    Started,
    /// 同じ読みの自分の変換が開始待ち・実行中。開始を抑止しただけで、既存の結果を使う意味は持たない
    AlreadyRunning,
    /// 別の読みの自分の変換が開始待ち・実行中。エンジンは何も変えない
    RunningOther,
    /// 変換器が旧所有者の側に残っている(1・2 と同じ条件)
    WorkerBusy,
    /// 変換器がどこにも無い(モデル未ロード)
    NotReady,
    /// 読みが空
    NoReading,
}

判定の順序は、入口の回収 → 読みが空(NoReading)→ bg_owner なしで BgSlot が Empty 以外(WorkerBusy)→ 自分の Queued / Running(読みが同じなら AlreadyRunning、違えば RunningOther)→ 自分の Done は現行どおりキーを問わず回収して開始(Started)→ 変換器が無い(NotReady)です。

  • AlreadyCurrent は取り下げます。 「同じ読みの自分の Done」は現行の bg_start と同じく回収して変換し直し、Started を返します。回収の前に判定して既存の結果を使う変更は (b) では入れません(挙動を変えないため)。AlreadyRunning は「開始を抑止した」だけの意味です
  • RunningOther の TSF 側は、現行の false と同じ遷移にします。Space は prev_bg_running_wait(自分の変換なので、現行どおり完了を待って回収し、新しい読みで開始し直す)。ライブ変換タイマーは次の tick を待ち、完了後の BgStart が Done を回収して開始します。確定時の追いつきは現行の bg_start refused と同じく、待たずに現在の preview で確定します
  • NotReady は変換器がどこにも無い場合だけになり、pending に残っている状態はここに入りません

4. Space の WorkerBusy

受入条件に入れます。on_convert は BgView の match で、Idle かつモデル未準備(start_model_wait)、自分の Running(prev_bg_running_wait)、WorkerBusy の 3 つを別の腕にします。WorkerBusy の腕はキー処理の中で待たず、辞書候補の Selecting とタイマーに渡します。タイマー側は ModelWait に待ちの理由(モデル読み込み/WorkerBusy)を持たせて共用しますが、入口の分岐は共用しません。

5. 読み取りの失敗

  • BgPeekTopCandidate: クライアントは Result<Option<String>, ReadError> で返します(応答は空文字列ではなく Option で運びます)。Ok(None) は「要求元は正しいが、要求元の結果がまだ無い・キーが違う」だけです。通信失敗と Rejected(OwnerMismatch | GenMismatch) は Err で、None に潰しません。TSF 側は次のとおりです
ライブ preview 確定時の追いつき
Ok(None) 現行どおり辞書由来の preview 現行どおり
Err(OwnerMismatch / GenMismatch) タイマーを止め、表示と復元元を保持(9/22) 確定は止めない(9/22 の Commit の扱い)。BgStart は送らない
Err(通信失敗) preview を書き換えず、その tick を終える 待たずに現在の preview で確定し、「未収束」として学習を見送る
  • BgTakeCandidates: ChangeOutcome::Candidates(Vec) をやめ、次の値にします
pub enum BgTakeOutcome {
    /// 要求元の結果を取り出した(候補が空のこともある)
    Taken(Vec<String>),
    /// 要求元の変換だが、まだ完了していない・キーが違う
    NotReady,
    /// BG の結果が要求元のものではない(`bg_owner` が自分でない)。状態は進めない
    NotYours,
}
  • 取得失敗を "idle" とみなしている箇所(candidate_window.rs:1496-1499 など)も対象に含めます。エンジンを取得できなかった tick は「状態不明」として何もせず(bg_start もフォールバックもしない)、抜けるのは 6 の上限だけにします。(b) で同種の箇所を洗い出して揃えます

6. TSF 側の上限

WorkerBusy の上限は #59 の監視に頼らず、すべて TSF 側に置きます。

経路 上限 達したとき
Space の WorkerBusy 待ち MODEL_WAIT_LIMIT_MS(10 秒) 待機を打ち切り、辞書候補のまま続ける
待機タイマー(Waiting) 既存の WAITING_LIMIT_MS(10 秒)の時計をそのまま使う 既存の経路で辞書候補の Selecting へ
ライブ変換タイマー 新設の上限(10 秒)。時計は「WorkerBusy を続けて見始めた時刻」で、他の値を見たら消す ライブタイマーを止め(モデル未準備のときと同じ止め方)、preview は辞書由来のまま続ける

ライブ変換は、上限の後に次の打鍵でタイマーが張り直されても時計を引き継ぎ、WorkerBusy が続く間は 1 回読んで止めます。Space は独立に効きます。

7. その他

  • 旧 variant の BgTakeCandidates: BG 系(BgStart / BgTakeCandidates / BgReclaim)は、TSF の送信の移行と _Reserved 化を同じコミットで行い、所有者を見ない旧経路が残る中間状態を作りません
  • ライブ変換の WorkerBusy 時の preview: 既存の immediate dict preview while bg=running(has_immediate_live_preview_candidate)の経路をそのまま使います。preview の作り方は足しません
  • テスト: pending の状態での WorkerBusy、入口の回収が空振りした場合の WorkerBusy、同じ所有者が Restore し直した後は自分の実行中の変換も WorkerBusy になること(意図した挙動として)を加えます。判定は (bg_owner, BgSlot) の純粋関数に分け、実モデルなしで書きます

この形でよければ (b) の実装に入ります。今回もコードを読んでの整理で、実機では確かめていません。

@fukuyori

fukuyori commented Oct 2, 2026

Copy link
Copy Markdown
Owner

10/2 の補足を確認しました。main b77d7df のコードと照合した結果を示します。7 点への回答は、方向としては合意できます。ただし、実装前に A〜E(下記)を PR 本文の受入条件に書くことを、着手の条件にします。

合意できる点

  • BgSlot(Empty / Queued / Running / Done { failed })を 1 回のロックで返し、pending を判定に含める形(1)。blocking lock を使い、poison を値に倒さず読み取りの失敗として返す点も賛成です。
  • 入口の回収が空振りした Done を WorkerBusy に含め、型の説明を「変換器が旧所有者の側に残っている」へ広げる形(2)。
  • AlreadyCurrent の取り下げと、AlreadyRunning(同じ読み)/ RunningOther(別の読み)の分離(3)。同じ読みの自分の Done を現行の bg_start と同じく回収して Started にするので、挙動は変わりません。
  • Space の WorkerBusy を on_convert の独立した腕にし、受入条件に入れること(4)。
  • BgPeekTopCandidate の Result<Option<_>, ReadError> 化と、BgTakeOutcome(Taken / NotReady / NotYours)への置き換え。取得失敗を "idle" とみなす箇所も対象に含める点(5)。
  • TSF 側の上限の表(6)。WAITING_LIMIT_MS と MODEL_WAIT_LIMIT_MS は現在のコードにあります(candidate_window.rs:1118、1125)。
  • BG 系の旧 variant は、送信の移行と _Reserved 化を同じコミットで行うこと(7)。

実装前に補ってほしい点

A. ABI の版を 2 か所とも上げる

BgSlot の読み取りはエンジン DLL への ABI の追加です。ENGINE_ABI_VERSION は crates/rakukan-engine/src/ffi.rs:20 と crates/rakukan-engine-abi/src/lib.rs:23(EXPECTED_ENGINE_ABI_VERSION)の 2 か所に定義があり、片方だけ上げると、ホストの版検査でエンジン DLL をロードできなくなります。2 か所を同じコミットで上げることと、cpu / vulkan / cuda の 3 variant すべてが新しい版を返すことを、(b) の受入条件に入れてください。

B. 読み取り要求の入口の回収が、エンジンの状態と結果を変えること

入口の bg_reclaim() は engine.kanji を書き換え、Done の候補を捨てます(conv_cache.rs:408 付近)。BgStatus や BgPeekTopCandidate のような読み取りの要求でも、結果が失われうることになります。次の 3 点を明記してください。

  • 回収は、engine_gen と owner の照合に通った後にだけ行うこと(Rejected になる要求では回収しない)。
  • 回収するのは bg_owner がない Done だけで、自分の Done は読み取りでは回収しないこと。
  • 同じ要求を繰り返しても、回収・破棄が繰り返されないこと(2 回目以降は回収するものが無く、状態が変わらない)。読み取りは Change と違って記録・再送の対象にならないので、回収が走ったことが応答の再送に影響しないこと。

C. 自分の Done の回収が空振りした場合の応答

BgStart の判定順序の「自分の Done は回収して開始(Started)」は、現行の bg_start では try_reclaim_done() が try_lock() なので、ロックが取れないと回収できません(conv_cache.rs:412)。回収できなかった場合、engine.kanji は空のままで、Started にも NotReady にもできません。この場合の応答(例: RunningOther ではなく、次の要求で回収し直す値)と、TSF の遷移を決めてください。WorkerBusy の定義は「旧所有者の側に残っている」なので、自分の Done をそこに含めないでください。

D. Queued が Running を隠す場合の記述

BgSlot は「pending があれば Queued を優先」としています。ワーカーは完了時に pending があると、状態を Done にせず変換器を pending へ渡します(conv_cache.rs:244)。Create や Reload でエンジンを作り直しても、プロセス内で共有される変換キャッシュには旧エンジンの Running が残りうる(※推測です。経路は未確認)ので、Running と pending が同時にある場合も Queued と報告されます。WorkerBusy と Running の判定には影響しませんが、次の 3 点を BgSlot の doc に書いてください。

  • same_reading は pending のキーで判定すること。
  • pending を優先する順位。
  • Queued の説明は「ワーカーがまだ拾っていない」だけでなく、旧要求が実行中の場合を含むこと。

E. BgTakeCandidates の再送

9/30 に合意したとおり、BgTakeCandidates は Change として採番します。新しい BgTakeOutcome(Taken / NotReady / NotYours)も保持して再送するので、同じ番号の再送で取り出しが再実行されないことを、テストを含めて受入条件に入れてください。

確認したいこと(実装の中で決めてよい)

  • 通信失敗時の「未収束」として学習を見送る処理(5 の表)は、既存の仕組みか新設かを PR 本文に書いてください。新設なら、見送った事実をログに残す点も受入条件に入れてください。
  • 読み取りの失敗(poison)をホストが返すときの応答の形(Error か、Rejected に足す variant か)。

了承する点

同じ所有者が Restore し直した後に、自分の実行中の変換も WorkerBusy として扱われる挙動は、意図したものとして了承します。上限(10 秒)に達したときは辞書候補で続けるので、利用者への影響は、旧変換 1 回分の待ちにとどまります。

判断

着手の条件は、A〜E の方針を PR 本文の受入条件として書くことです。コードでの実装と確認は (b) の中で行い、受入条件の達成は (b) のレビューで確かめます。この条件が書かれた時点で、(b) の実装に着手してください。今回も、コードを読んでの照合で、実機では確かめていません。

@nick20002005

Copy link
Copy Markdown
Contributor Author

A〜E と確認事項 2 点への回答です。(b) の PR 本文の受入条件には、下記をそのまま載せます。

A. ABI の版

crates/rakukan-engine/src/ffi.rs の ENGINE_ABI_VERSION と crates/rakukan-engine-abi/src/lib.rs の EXPECTED_ENGINE_ABI_VERSION を、同じコミットで 10 → 11 に上げます。cpu / vulkan / cuda の 3 variant がすべて 11 を返すことを受入条件に入れます。

B. 読み取り要求の入口の回収

  • 回収は engine_gen と owner の照合に通った後にだけ行います。Rejected になる要求では回収しません。
  • 回収するのは bg_owner がない Done だけです。自分の Done は読み取りでは回収しません。
  • 回収は冪等にします。2 回目以降は回収対象が無く、状態は変わりません。読み取りは記録・再送の対象外なので、回収が走ったかどうかは応答にも再送にも影響しません。
  • 上の 3 点はテストで確かめます(照合に落ちた要求で Done が残ること、自分の Done が読み取り後も残ること、同じ読み取りを 2 回送って状態が同じこと)。

C. 自分の Done の回収が空振りした場合

BgStart の中での自分の Done の回収は、try_lock() ではなく BgSlot と同じ blocking lock で行います。ワーカーがロックを持つのは状態を書き換える短い区間だけなので、待ちは短く、空振りの状態そのものが起きません。poison のときは読み取りの失敗と同じ扱いで返します(下記)。WorkerBusy は「旧所有者の側に残っている」場合に限り、自分の Done は含めません。

D. BgSlot の doc

次の 3 点を doc に書きます。

  • same_reading は、pending があれば pending のキーで判定する。
  • 順位は pending(Queued)> Running > Done > Empty。
  • Queued は「ワーカーがまだ拾っていない」場合に加え、旧要求の変換が実行中で次の要求が待っている場合を含む。

E. BgTakeCandidates の再送

BgTakeCandidates は Change として採番し、BgTakeOutcome を結果として保持します。同じ番号の再送では保持した結果を返し、取り出しは再実行しません。テスト(同じ番号で 2 回送り、2 回目が保持した Taken を返し、変換器の状態が 1 回目の後と同じであること)を受入条件に入れます。

確認事項

  • 通信失敗時の学習の見送り: 見送りの結果(LearnAction::Skip)は既存のものを使いますが、「通信失敗で未収束だから見送る」という判定は新設です。見送ったことはログに残し、受入条件に入れます。
  • 読み取りの失敗(poison)の応答: 既存の Response::Error を使います。Rejected は「照合に落ちた」という意味に限定しておきたいためです。TSF 側では Error を ReadError として扱います。

この内容で (b) に着手します。

@fukuyori

fukuyori commented Oct 3, 2026

Copy link
Copy Markdown
Owner

A〜E と確認事項 2 点への回答を確認しました。main b77d7df のコードと照合しました。

A(ABI を 10 → 11、2 か所を同じコミットで更新)、B(照合後にだけ回収、bg_owner のない Done だけ、冪等、3 点のテスト)、D(BgSlot の doc の 3 点と順位)、E(BgTakeCandidates の採番と再送、テスト)は、依頼したとおりです。確認事項の 2 点も、次の既存の型の存在を確かめました。

  • LearnAction::Skip(state.rs:951)。見送りの判定の新設とログも了承します。
  • Response::Error(String)(protocol.rs:484)。poison を Error で返し、Rejected を「照合に落ちた」に限定する整理に賛成です。

(b) の着手を了承します。C について、実装の中で次の 3 点を決め、PR 本文に書いてください。

C について、PR 本文に書いてほしいこと

  1. 要求の入口の回収との関係。 10/2 の案では、要求の入口の回収(bg_owner のない Done の回収)が try_lock で空振りした Done を WorkerBusy に含めていました。今回、BgStart の中の、自分の Done の回収だけを blocking にすると読めます。要求の入口の回収も blocking にするのか、try_lock のまま WorkerBusy に含めるのかを示してください。ワーカーのロックの保持が短いなら、入口も blocking にして、WorkerBusy から「未回収の Done」を外す整理もありえます。どちらでも構いません。

  2. 既存の呼び出し元への影響。 回収の経路は 2 つあります。

    • bg_start() が try_reclaim_done() を直接呼ぶ経路(lib.rs:1294)と、start_load_model の経路(ffi.rs:557)。
    • bg_reclaim() → reclaim_nonblocking() → try_reclaim_done() の経路(lib.rs:1377、conv_cache.rs:428)。RPC の BgReclaim と Restore から呼ばれます。

    try_reclaim_done() そのものを blocking に変えると、Restore や BgReclaim の回収にも影響します。BgStart 用に別の関数を足すのか、既存の関数を変えるのか、後者なら bg_reclaim() 経路への影響も書いてください。

  3. ロックの順序。 BgStart や Restore は、エンジンのロックを持ったまま回収を呼びます。この上でキャッシュのロックを blocking で取ることになります。ワーカーは変換中はキャッシュのロックを持たない(conv_cache.rs:176 付近)ので、私が読んだ範囲では循環は見当たりません。ただし、エンジンロック → キャッシュロック → stall watchdog(キャッシュの状態を読む)の順序と、待ちが長引いたときの影響を、PR 本文に書いてください。

レビューで見る点

「空振りの状態そのものが起きません」で防げるのは、競合を理由に try_lock() が即座に失敗することだけです。ロックを取った後の状態の確認、poison の扱い、待ちが実際に長引く場合は別に必要です。また、これはワーカーがロックを持つ時間が短いことが前提です。変換自体はロックの外で行われる(conv_cache.rs のワーカー本体)ので、私が読んだ範囲では、前提は成り立つと見ています。ただし、blocking lock の待ちはキー処理の中で起きます。(b) のレビューでは、次の 3 点を確認します。

  • ワーカーが Done を書く区間で、ロックを保持したまま重い処理をしていないこと。
  • poison のときに Response::Error へ伝搬する経路が、BgStart と BgStatus の両方で実装・テストされていること。
  • 待ちが長引いた場合の扱い(上限を置くか、置かない理由)が、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