@@ -16,11 +16,21 @@ export type CatalogQuery = (params: {
1616 supportedModels ( ) : Promise < unknown > ;
1717} ;
1818
19- /** 何も送らないプロンプト。カタログ取得は init のみで完結するため入力は不要。 */
20- async function * idlePrompt ( ) : AsyncGenerator < SDKUserMessage > {
21- // 中断は abortController が行う。ここで解決しない Promise を待つことで
22- // 「入力待ちのまま」= モデル推論を一切走らせない状態を保つ。
23- await new Promise < never > ( ( ) => { } ) ;
19+ /**
20+ * 何も送らないプロンプト。カタログ取得は init のみで完結するため入力は不要で、
21+ * 待ち続けることで「入力待ちのまま」= モデル推論を一切走らせない状態を保つ。
22+ *
23+ * 待ちは abort で解ける。決して解決しない Promise にすると、この async generator は
24+ * `.return()` を完了できず、`for await … break` する消費者が永久に待つことになる。
25+ */
26+ async function * idlePrompt ( signal : AbortSignal ) : AsyncGenerator < SDKUserMessage > {
27+ await new Promise < void > ( ( resolve ) => {
28+ if ( signal . aborted ) {
29+ resolve ( ) ;
30+ return ;
31+ }
32+ signal . addEventListener ( 'abort' , ( ) => resolve ( ) , { once : true } ) ;
33+ } ) ;
2434}
2535
2636/**
@@ -30,18 +40,24 @@ async function* idlePrompt(): AsyncGenerator<SDKUserMessage> {
3040 * すぐ abort する。**モデル推論は走らないのでトークン消費もコストも無い**
3141 * (実測 0.3〜2 秒。設定・プラグインの読み込み量で変わる)。
3242 *
33- * 失敗・タイムアウトでは投げずに空配列を返す。呼び出し側(合成ルート)が
34- * `FALLBACK_MODEL_OPTIONS` へ落とすため、カタログが取れなくても /model は動く。
43+ * 失敗・タイムアウトでは投げずに空配列を返す。呼び出し側(`ui/hooks.ts` の
44+ * `useModelCatalog`)が `FALLBACK_MODEL_OPTIONS` へ落とすため、カタログが
45+ * 取れなくても /model は動く。
46+ *
47+ * `opts.signal` を渡すと取得を外から打ち切れる。合成ルートは終了時にこれを
48+ * abort する(取得中に終了されたときサブプロセスを残さないため)。
3549 */
3650export async function fetchModelCatalog (
3751 queryFn : CatalogQuery ,
38- opts : { cwd : string } ,
52+ opts : { cwd : string ; signal ?: AbortSignal } ,
3953) : Promise < ModelOption [ ] > {
4054 const abortController = new AbortController ( ) ;
41- const timer = setTimeout ( ( ) => abortController . abort ( ) , CATALOG_TIMEOUT_MS ) ;
55+ const abort = ( ) => abortController . abort ( ) ;
56+ opts . signal ?. addEventListener ( 'abort' , abort , { once : true } ) ;
57+ let timer : ReturnType < typeof setTimeout > | undefined ;
4258 try {
4359 const handle = queryFn ( {
44- prompt : idlePrompt ( ) ,
60+ prompt : idlePrompt ( abortController . signal ) ,
4561 options : {
4662 cwd : opts . cwd ,
4763 abortController,
@@ -50,12 +66,29 @@ export async function fetchModelCatalog(
5066 settingSources : [ 'project' ] ,
5167 } ,
5268 } ) ;
53- return toModelOptions ( await handle . supportedModels ( ) ) ;
69+ const catalog = handle . supportedModels ( ) ;
70+ // タイムアウト勝ちの後に届いた rejection を unhandled にしない。
71+ catalog . catch ( ( ) => { } ) ;
72+ // タイムアウトは自前で決着させる(abort 時に SDK が必ず reject することに
73+ // 依存すると、SDK の内部挙動が変わったとき「取得中…」で固まる)。
74+ const timedOut = Symbol ( 'timeout' ) ;
75+ const result = await Promise . race ( [
76+ catalog ,
77+ new Promise < typeof timedOut > ( ( resolve ) => {
78+ timer = setTimeout ( ( ) => resolve ( timedOut ) , CATALOG_TIMEOUT_MS ) ;
79+ // 取得中に終了されても、このタイマーがプロセスを生かし続けないように。
80+ timer . unref ?.( ) ;
81+ } ) ,
82+ ] ) ;
83+ return result === timedOut ? [ ] : toModelOptions ( result ) ;
5484 } catch {
5585 return [ ] ;
5686 } finally {
57- clearTimeout ( timer ) ;
58- // 取得できたら即座にサブプロセスを畳む(常駐させない)。
87+ if ( timer !== undefined ) {
88+ clearTimeout ( timer ) ;
89+ }
90+ opts . signal ?. removeEventListener ( 'abort' , abort ) ;
91+ // どの経路でも即座にサブプロセスを畳む(常駐させない)。
5992 abortController . abort ( ) ;
6093 }
6194}
0 commit comments