diff --git a/CLAUDE.md b/CLAUDE.md index b5c1e46..0e8dc7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: `/auto-audit` でコードベース全体をバグ・脆弱性観点で巡回監査し,再現テストで証明できた 7 件を自動修正(保存パス無害化・設定値パース・資源解放・ロケール依存・ゲインのクランプ等).続けて重大度「高」の「撮影失敗が無通知でデータ破損する」問題を即中止+ダイアログ通知に修正.未修正の指摘は `.claude/auto-audit-report.md` に集約 +最新: 監査(`/auto-audit`)で検出したバグ・脆弱性を修正.再現テストで証明できた 7 件を自動修正したのち,重大度「高」の「撮影失敗が無通知でデータ破損する」問題を即中止+ダイアログ通知に,続けて撮影経路の例外境界 3 件(例外時の UI 固着・保存スレッドの永久ブロック・撮影終了処理のスキップ)を修正.未修正の指摘は `.claude/auto-audit-report.md` に集約 ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index e72186f..55b0474 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -21,6 +21,32 @@ // インターフェース public abstract bool Connect(); public abstract void Disconnect(); + + /// + /// 撮影セッション本体(派生クラスで実装する). + /// カメラ固有の準備を除き,基底クラスのライフサイクルメソッドを次の順序で必ず呼ぶこと. + /// 順序を崩すと,撮影ボタンの固着・SaveThread のリーク・保存完了メッセージの誤表示が起きる. + /// + /// + /// WaitPreviousSaveThread(); // 前回の保存完了を待つ(この前にセッションを初期化しない) + /// PrepareShotSession(); // 状態フラグの初期化 + /// ResetShotQueue(); // 保存キューの作り直し + /// StartSaveThread(numImages); // SaveThread 起動(以降 EnableShots(true) は SaveThread の責務) + /// try { + /// WaitSaveThreadReady(); // consumer の準備完了を待つ + /// BeginShot(); // カメラ固有の撮影開始処理 + /// RunShotLoop(numImages, interval); + /// } finally { + /// EndShotSession(); // EndShot → FinalizeShotSession(順序厳守) + /// } + /// + /// + /// カメラ固有の事情で ResetShotQueue / StartSaveThread / BeginShot の位置を前後させる場合でも, + /// 「PrepareShotSession より後」「StartSaveThread 以降は必ず EndShotSession に到達する」の + /// 2 点は守ること(IScam は Sink 切り替えの都合で BeginShot を try の内側で先に呼ぶ). + /// + /// 撮影枚数 + /// 撮影間隔(ms) protected abstract void Shot(int numImages=1, int interval=0); #if WHITEBOARD @@ -64,30 +90,59 @@ protected Rect _roi; protected Thread _saveThread = null; // SaveThreadへの参照を保持(前回完了待機用) protected ManualResetEventSlim _saveThreadReady = new ManualResetEventSlim(false); // SaveThread起動完了の同期 + + // --- 撮影セッションの状態フラグ --------------------------------------------------- + // 4 つのフラグは「役割・スレッド可視性の前提・リセット地点」がそれぞれ異なるため統合しない. + // + // | フラグ | 書き手 → 読み手 | 可視性 | リセット地点 | + // | ------------------ | ---------------------------- | ------------ | ------------------- | + // | _shotAborted | RunShotLoop → SaveThread | volatile 必須 | PrepareShotSession | + // | _abortReason | RunShotLoop → ShotOne/Multi | 同一スレッド | PrepareShotSession / 通知時 | + // | _shotLoopFinished | RunShotLoop → FinalizeShotSession | 同一スレッド | PrepareShotSession | + // | _saveThreadStarted | Shot → Form1 の例外境界 | 同一スレッド | BeginShotRequest / PrepareShotSession | + // + // 【重要】_shotAborted 以外は「Shot と同一の UI スレッド内で完結する」ことが前提である. + // Shot をワーカースレッド化する場合は可視性の前提から見直すこと. + // 撮影が途中で中止されたか.撮影スレッド(RunShotLoop)が書き,別スレッドの SaveThread が - // 読むため volatile とする(_abortReason とは可視性の前提が異なるため統合しない). + // 読むため volatile とする(他のフラグとは可視性の前提が異なるため統合しない). protected volatile bool _shotAborted = false; // 中止理由(EndShot 完了後にユーザー通知するため保持する.通知時にクリアする). - // 【前提】RunShotLoop(書き手)も ShotOne/ShotMulti の通知(読み手)も同一の UI スレッドで - // 同期的に実行されるため volatile は不要.Shot をワーカースレッド化する場合は要見直し. private string _abortReason = null; // RunShotLoop が最後まで実行され,_shots.CompleteAdding() を呼び終えたか. // FinalizeShotSession が CompleteAdding を二重に呼ばないための判定に使う. - // (_abortReason と同じく Shot と同一スレッド内で完結するため volatile は不要) private bool _shotLoopFinished = false; // 今回の撮影セッションで SaveThread を起動したか(IsSaveThreadStarted で公開する). - // 書き込みも読み出しも UI スレッド(Shot と Form1 の例外境界)で完結するため volatile は不要. + // 他の 3 つより早く(Shot に入る前に)落とす必要があるため BeginShotRequest でもリセットする. private bool _saveThreadStarted = false; + // SaveThread の起動完了待ちタイムアウト(通常は即座に完了する.無限待機の回避が目的) + private static readonly TimeSpan SaveThreadReadyTimeout = TimeSpan.FromSeconds(5); + // 保存キュー解放待ちの無通信タイムアウト + // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険) + private static readonly TimeSpan DrainIdleTimeout = TimeSpan.FromSeconds(30); + // 保存キュー解放時の 1 回あたりの取り出し待ち時間(ms)(無通信時間の判定粒度) + private const int DrainPollMs = 100; + /// - /// 今回の撮影セッションで画像保存スレッド(SaveThread)を起動したか. - /// 起動済みであれば,撮影ボタンの再有効化(EnableShots(true))は SaveThread の finally が + /// 画像保存スレッド(SaveThread)が起動済みで,かつまだ生存しているか. + /// true であれば,撮影ボタンの再有効化(EnableShots(true))は SaveThread の finally が /// 必ず行う(CompleteAdding には全経路で到達するため SaveThread は必ず完走する). /// このとき呼び出し元(Form1 の例外境界)が先に再有効化すると,保存中に次の撮影へ再入でき, /// UI スレッドの _saveThread.Join() と SaveThread 側の Control.Invoke が相互待機して /// アプリがハングする.そのため呼び出し元は本プロパティが false の場合のみ再有効化すること. + /// 【重要】フラグ単独では判定しない.フラグのリセットは Shot 内(PrepareShotSession)で行うため, + /// Shot に入る前(SetSaveFolder / SetSnapParam / 前回 SaveThread の Join)で例外が出ると + /// 前回セッションの true が残り,誰も再有効化しないまま撮影ボタンが固着する. + /// 生存判定を併用すれば,既に終了した SaveThread は再有効化済みのため, + /// 呼び出し元が重ねて EnableShots(true) を呼んでも無害である. + /// 【前提条件】撮影ボタンは画像の保存完了まで有効化されないこと(撮影中〜保存中は無効のまま). + /// この前提が崩れる変更(保存を待たずに有効化する,撮影中に再校正等から Shot へ入れる)を加えると, + /// 前回の SaveThread が生存中に次の Shot へ入れてしまい,UI スレッドの _saveThread.Join() と + /// SaveThread 側の Control.Invoke が相互待機してアプリがハングする. + /// 本プロパティによる判定だけでは防げないため,その場合は撮影の再入制御そのものを設計し直すこと. /// - public bool IsSaveThreadStarted => _saveThreadStarted; + public bool IsSaveThreadStarted => _saveThreadStarted && _saveThread != null && _saveThread.IsAlive; // プライベートメンバ readonly float UpdateRate; @@ -135,6 +190,7 @@ /// 画像撮影1枚 /// public void ShotOne() { + BeginShotRequest(); SetSaveFolder(_form.GetDataName()); SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", "1"); @@ -146,6 +202,7 @@ /// 複数画像撮影 /// public void ShotMulti() { + BeginShotRequest(); SetSaveFolder(_form.GetDataName()); SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}"); @@ -155,6 +212,17 @@ } /// + /// 撮影要求の受け付け開始(ShotOne / ShotMulti の先頭で,他の何よりも先に呼ぶこと). + /// 「今回のセッションで SaveThread を起動した」フラグを落とし,前回セッションの値が + /// Shot 到達前の例外(SetSaveFolder のフォルダ作成失敗等)に持ち越されないようにする. + /// _shotAborted 等は前回の SaveThread がまだ読み得るためここではクリアせず, + /// 前回 SaveThread の Join 後(PrepareShotSession)に行う. + /// + private void BeginShotRequest() { + _saveThreadStarted = false; + } + + /// /// 撮影完了後の後処理(Shot から戻った直後に呼ぶこと). /// 正常終了時は完了音を鳴らして「撮影終了」を表示する. /// 中止時は完了音・完了メッセージを出さず(SaveThread が中止を表示する),中止理由を通知する. @@ -247,12 +315,43 @@ Debug.WriteLine($"画像保存に失敗しました({saveCount + 1}枚目): {ex}"); saveError = ex.Message; } finally { + // 【重要】順序は DrainShots → ShowError → EnableShots(true) を維持すること. + // モーダル表示中は撮影ボタンが無効のままなので Shot への再入(=Join)が起きず, + // Invoke のデッドロックを避けられる. DrainShots(shots); if (saveError != null) { - _form.ShowError($"画像の保存に失敗しました.\r\n{saveError}"); + NotifySaveFailed(saveError); } - // 撮影ボタンの再有効化は本スレッドの責務(Form1 の例外境界は再有効化しない). + RestoreShotButtons(); + } + } + + /// + /// 保存失敗をユーザーへ通知する(SaveThread の finally から呼ぶ). + /// フォーム破棄後(アプリ終了時に保存が残っている場合)の Control.Invoke は + /// ObjectDisposedException / InvalidOperationException を投げる.finally から例外が出ると + /// バックグラウンドスレッドの未処理例外となりプロセスが即終了するため個別に捕捉する. + /// + /// 保存失敗の理由(例外メッセージ) + private void NotifySaveFailed(string saveError) { + try { + _form.ShowError($"画像の保存に失敗しました.\r\n{saveError}"); + } catch (Exception ex) { + Debug.WriteLine($"保存失敗の通知に失敗しました: {ex}"); + } + } + + /// + /// 撮影ボタンを再有効化する(SaveThread の finally から呼ぶ). + /// 再有効化は SaveThread 起動後の全経路で本スレッドの責務であり, + /// Form1 の例外境界は再有効化しない(IsSaveThreadStarted 参照). + /// NotifySaveFailed と同じ理由で,Invoke の失敗はログのみに留める. + /// + private void RestoreShotButtons() { + try { _form.EnableShots(true); + } catch (Exception ex) { + Debug.WriteLine($"撮影ボタンの再有効化に失敗しました: {ex}"); } } @@ -261,18 +360,22 @@ /// producer が撮影を続けている可能性があるため,CompleteAdding 済みかつ空になるまで待つ. /// CompleteAdding には全経路で到達するが(RunShotLoop / FinalizeShotSession), /// 万一到達しない異常時にスレッドが残らないよう待機時間の上限を設ける. + /// 【重要】上限は「総経過時間」ではなく「無通信時間」で判定する.撮影枚数・間隔は UI から + /// 任意に設定できるため(例: 60 枚 × 1000ms),総経過時間で打ち切ると正常な長時間連写の + /// 途中で誤って待機を終え,未消費 Mat のリークと撮影中の EnableShots(true) を招く. /// /// 対象の保存キュー private void DrainShots(BlockingCollection shots) { - var deadline = Stopwatch.StartNew(); + var idle = Stopwatch.StartNew(); try { while (!shots.IsCompleted) { - if (deadline.Elapsed > DrainTimeout) { + if (idle.Elapsed > DrainIdleTimeout) { Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました"); break; } - if (shots.TryTake(out var remaining, 100)) { + if (shots.TryTake(out var remaining, DrainPollMs)) { remaining?.Dispose(); + idle.Restart(); // producer が生きている間は待機を継続する } } } catch (Exception ex) { @@ -282,9 +385,6 @@ } } - // 保存キュー解放待ちの上限(CompleteAdding が来ない異常時にスレッドを残さないための保険) - private static readonly TimeSpan DrainTimeout = TimeSpan.FromSeconds(30); - /// /// 校正フレーム処理の共通骨格(テンプレートメソッド) /// imgt が用意された後の校正/検出フローを担う. @@ -317,6 +417,20 @@ } /// + /// 前回の撮影セッションの SaveThread が完了するまで待機する + /// (派生クラスの Shot の先頭で,PrepareShotSession より前に呼ぶこと). + /// 低スペック PC で保存と次の撮影が競合するのを防ぐ. + /// 【重要】撮影セッションの初期化(PrepareShotSession)は必ずこの待機の後に行う. + /// 先に _shotAborted をクリアすると,前回が中止で終わった直後の撮影で,まだ走っている + /// 前回の SaveThread が中止フラグを false と読み,「全ての画像保存完了」と誤表示する. + /// + protected void WaitPreviousSaveThread() { + if (_saveThread != null && _saveThread.IsAlive) { + _saveThread.Join(); + } + } + + /// /// 撮影セッションの開始準備(派生クラスの Shot で,SaveThread 起動より前に呼ぶこと). /// 中止状態と「撮影ループを完走したか」のフラグを初期化する. /// RunShotLoop に到達しないまま例外で抜けた場合を FinalizeShotSession が検出できるようにする. @@ -329,22 +443,69 @@ } /// - /// SaveThread の起動を記録する(派生クラスの Shot で Thread.Start() の直後に呼ぶこと). - /// 以降は EnableShots(true) の責任が SaveThread に移る(IsSaveThreadStarted 参照). + /// 保存キュー(_shots)を今回のセッション用に作り直す + /// (派生クラスの Shot で,SaveThread 起動より前に呼ぶこと). + /// 新しいインスタンスを作成してから古い BlockingCollection を破棄する + /// (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを + /// 指したままとなり,後段の CompleteAdding が ObjectDisposedException になる). /// - protected void MarkSaveThreadStarted() { + protected void ResetShotQueue() { + var previousShots = _shots; + _shots = new BlockingCollection(); + previousShots?.Dispose(); + } + + /// + /// 画像保存スレッド(SaveThread)を起動し,起動済みであることを記録する + /// (派生クラスの Shot で,ResetShotQueue の後・RunShotLoop より前に呼ぶこと). + /// 【重要】本メソッドを呼んだ以降は,どの経路でも EndShotSession に到達させること. + /// 到達しないと SaveThread が終了通知を受け取れず永久にブロックし, + /// 撮影ボタンを再有効化する EnableShots(true) にも到達しない. + /// + /// 撮影枚数(保存進捗の表示に使う) + protected void StartSaveThread(int numImages) { + // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする + _saveThreadReady.Reset(); + + var thread = new Thread(() => SaveThread(numImages)); + _saveThread = thread; // 参照を保持(次回の完了待機用) + thread.Start(); + // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) _saveThreadStarted = true; } /// - /// 撮影セッションの終端処理(派生クラスの Shot の finally で,撮影終了処理の後に呼ぶこと). + /// SaveThread が起動完了するまで待機する(consumer 準備完了を保証する). + /// タイムアウトしても撮影は続行する(保存が遅れるだけで,キューは失われない). + /// + protected void WaitSaveThreadReady() { + if (!_saveThreadReady.Wait(SaveThreadReadyTimeout)) { + Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); + } + } + + /// + /// 撮影セッションの終端処理(派生クラスの Shot の finally で呼ぶこと). + /// カメラ状態の復帰(EndShot)が失敗しても SaveThread を必ず終了させるため, + /// EndShot → FinalizeShotSession の順序をネストした finally で保証する. + /// + protected void EndShotSession() { + try { + EndShot(); + } finally { + FinalizeShotSession(); + } + } + + /// + /// 保存キューへの追加終了を SaveThread へ通知する(EndShotSession からのみ呼ぶ). /// RunShotLoop に到達する前に例外が発生すると _shots.CompleteAdding() が呼ばれず, /// SaveThread が GetConsumingEnumerable で永久にブロックしてスレッドがリークし, /// 撮影ボタンを再有効化する EnableShots(true) にも到達しなくなる. /// これを防ぐため,撮影ループ未完走の場合に限り中止扱いにして CompleteAdding を呼ぶ. /// (完走時は RunShotLoop の finally が呼び済みのため,ここでは呼ばない) /// - protected void FinalizeShotSession() { + private void FinalizeShotSession() { if (_shotLoopFinished) return; // RunShotLoop が CompleteAdding 済み(二重呼び出し防止) // 撮影枚数が揃っていないため中止扱いとし,SaveThread に「全て保存完了」と表示させない. // 中止理由は設定しない(想定外例外は呼び出し元 Form1 の例外境界が通知する). diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs index 26458eb..5470817 100644 --- a/TIASshot/Cameras/IScam.cs +++ b/TIASshot/Cameras/IScam.cs @@ -28,6 +28,9 @@ FrameSnapSink _snapSink; FrameSnapSink _snapSinkRef; + // 1 フレーム取得(SnapSingle)のタイムアウト + private static readonly TimeSpan SnapTimeout = TimeSpan.FromSeconds(5); + /// /// IScamコンストラクタ /// @@ -165,19 +168,14 @@ } /// - /// 撮影 + /// 撮影(IScam 固有) + /// 呼び出し順序は CameraBase.Shot の規定に準じるが,BeginShot(Sink 切り替え)だけは + /// 捨てフレームの取得に必要なため try の内側で先に呼ぶ. /// - /// - /// + /// 撮影枚数 + /// 撮影間隔(ms) protected override void Shot(int numImages = 1, int interval = 0) { - // 前回の SaveThread が完了するまで待機(低スペックPCでの競合回避) - // 【重要】撮影セッションの初期化(PrepareShotSession)は必ずこの待機の後に行う. - // 先に _shotAborted をクリアすると,前回が中止で終わった直後の撮影で,まだ走っている - // 前回の SaveThread が中止フラグを false と読み,「全ての画像保存完了」と誤表示する. - if (_saveThread != null && _saveThread.IsAlive) { - _saveThread.Join(); - } - + WaitPreviousSaveThread(); PrepareShotSession(); // Sink 切り替え以降は,どの経路でも撮影終了処理(EndShot)と CompleteAdding に到達させる. @@ -185,39 +183,16 @@ // ライブプレビューが停止したままになり,また SaveThread が終了通知を受け取れず永久にブロックする. try { BeginShot(); + ResetShotQueue(); - // 新しいインスタンスを作成してから古い BlockingCollection を破棄する - // (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを - // 指したままとなり,後段の CompleteAdding が ObjectDisposedException になる) - var previousShots = _shots; - _shots = new BlockingCollection(); - previousShots?.Dispose(); + _snapSinkRef.SnapSingle(SnapTimeout); // 最初のフレームを捨てる - _snapSinkRef.SnapSingle(TimeSpan.FromSeconds(5)); // 最初のフレームを捨てる - - // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする - _saveThreadReady.Reset(); - - var thread = new Thread(() => SaveThread(numImages)); - _saveThread = thread; // 参照を保持(次回の完了待機用) - thread.Start(); - // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) - MarkSaveThreadStarted(); - - // SaveThread が起動完了するまで待機(consumer 準備完了を保証) - // タイムアウト設定で無限待機を回避(通常は即座に完了するはず) - if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { - Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); - } + StartSaveThread(numImages); + WaitSaveThreadReady(); RunShotLoop(numImages, interval); } finally { - // EndShot が失敗しても SaveThread を必ず終了させる(順序: カメラ状態復帰 → 終了通知) - try { - EndShot(); - } finally { - FinalizeShotSession(); - } + EndShotSession(); } } @@ -236,7 +211,7 @@ /// /// 撮影した Mat(呼び出し元が _shots に追加する) protected override Mat CaptureFrame() { - var buffer = _snapSinkRef.SnapSingle(TimeSpan.FromSeconds(5)); + var buffer = _snapSinkRef.SnapSingle(SnapTimeout); using (Mat img = Mat.FromPixelData(buffer.FrameType.Height, buffer.FrameType.Width, MatType.CV_8UC3, buffer.GetIntPtr())) { using (Mat imgt_full = img.T()) { return new Mat(imgt_full, _roi); diff --git a/TIASshot/Cameras/Lucam.cs b/TIASshot/Cameras/Lucam.cs index 93f65d8..176c590 100644 --- a/TIASshot/Cameras/Lucam.cs +++ b/TIASshot/Cameras/Lucam.cs @@ -217,35 +217,18 @@ } /// - /// 撮影 + /// 撮影(Lucam 固有) + /// 呼び出し順序は CameraBase.Shot の規定どおり + /// (前回待機 → セッション初期化 → キュー作り直し → SaveThread 起動 → 撮影 → 終端処理). /// - /// - /// + /// 撮影枚数 + /// 撮影間隔(ms) protected override void Shot(int numImages=1, int interval=0) { SetSnapParam(); - - // 前回の SaveThread が完了するまで待機(低スペックPCでの競合回避) - if (_saveThread != null && _saveThread.IsAlive) { - _saveThread.Join(); - } - + WaitPreviousSaveThread(); PrepareShotSession(); - - // 新しいインスタンスを作成してから古い BlockingCollection を破棄する - // (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを - // 指したままとなり,後段の CompleteAdding が ObjectDisposedException になる) - var previousShots = _shots; - _shots = new BlockingCollection(); - previousShots?.Dispose(); - - // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする - _saveThreadReady.Reset(); - - var thread = new Thread(() => SaveThread(numImages)); - _saveThread = thread; // 参照を保持(次回の完了待機用) - thread.Start(); - // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) - MarkSaveThreadStarted(); + ResetShotQueue(); + StartSaveThread(numImages); // SaveThread 起動後は,どの経路でも撮影終了処理(EndShot)と CompleteAdding に到達させる. // BeginShot(LucamEnableFastFrames は失敗しても false を返すだけで例外を投げないが, @@ -253,20 +236,11 @@ // EndShot 未実行のまま LucamDisableFastFrames が呼ばれず高速フレームモードが残り, // また SaveThread が終了通知を受け取れず永久にブロックしてしまう. try { - // SaveThread が起動完了するまで待機(consumer 準備完了を保証) - if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { - Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); - } - + WaitSaveThreadReady(); BeginShot(); RunShotLoop(numImages, interval); } finally { - // EndShot が失敗しても SaveThread を必ず終了させる(順序: カメラ状態復帰 → 終了通知) - try { - EndShot(); - } finally { - FinalizeShotSession(); - } + EndShotSession(); } } diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 5a169ee..a742849 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -167,7 +167,9 @@ Debug.WriteLine($"撮影に失敗しました: {ex}"); // カメラの状態復帰(EndShot)と SaveThread の終了通知は Shot 内の finally で完了済み. // SaveThread 未起動の経路だけをここで復帰させる(起動済みなら SaveThread に任せる). - if (_camera == null || !_camera.IsSaveThreadStarted) EnableShots(true); + if (_camera == null || !_camera.IsSaveThreadStarted) { + EnableShots(true); + } ShowError($"撮影に失敗しました.\r\n{ex.Message}"); } } diff --git "a/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" index 8b28a37..5fe9e52 100644 --- "a/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_01_\346\251\237\350\203\275\344\273\225\346\247\230.md" @@ -69,6 +69,9 @@ - カメラからのフレーム取得に失敗した場合,**その時点で撮影全体を中止する**(残り枚数は撮影しない). - 中止した旨をダイアログで通知する.通知は撮影終了処理(プレビュー復帰)の完了後に表示される. - 取得済みの画像は破棄せずそのまま保存し,`Info.csv` に中止した旨と理由を記録する.失敗した1枚は成功として記録されない. +- 撮影開始前の準備(保存フォルダの作成等)やカメラ制御でエラーが起きた場合も,ダイアログで通知して撮影ボタンを操作可能な状態に戻す. +- 画像の保存に失敗した場合は,保存処理の終了後にダイアログで通知する. +- ※ 撮影ボタンは保存が完了するまで無効のままとなる(保存中に次の撮影を開始できない). ## データ保存 (Data Saving) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 7eceef7..c9faefe 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -108,3 +108,10 @@ - 失敗パターンの圧縮: ① 中止通知を撮影ループ内で出す → `MessageBox` のモーダルループがメッセージをポンプするため,保存スレッドの `EnableShots(true)` がダイアログ表示中に届き,撮影終了処理(`LucamDisableFastFrames` / Sink 復帰)未完了のまま再入し得る → ② 通知を `Shot()` の戻り直後へ移動.`EndShot()` 直後ではなく `Shot()` 戻り後にしたのは,IScam が `EndShot()` 空実装で実処理を `Shot` にインライン展開しているため. - 中止状態は `_shotAborted`(保存スレッドが読むため volatile)と中止理由(UI スレッド内で完結)に分離した.統合すると「想定外例外による中止(理由なし・通知しない)」が表現できなくなるか可視性の前提が崩れるため,統合せずコメントで意図を明示. - 後段: 監査で自動修正しなかった指摘は `.claude/auto-audit-report.md` に集約(要人間対応).とくに撮影経路の例外境界(`btnShot*_Click` の try-catch 欠如,`BeginShot` 例外時に `CompleteAdding()` へ到達せず UI が固着する穴,想定外例外時に `EndShot()` がスキップされる件)は同根で,まとめて設計で潰すのが望ましい. + +- [x] **撮影経路の例外境界を整備し例外時の UI 固着とスレッドリークを解消** (2026-08-10): 監査で報告に落としていた同根 3 件(撮影ボタンハンドラに例外境界が無い/`CompleteAdding()` 未到達で保存スレッドが永久ブロックする/想定外例外で撮影終了処理がスキップされる)をまとめて修正し,あわせて保存スレッド自身の例外境界も追加した. + - 動機: いずれも「例外が出た瞬間に撮影ボタンが二度と有効化されず,アプリ再起動が必要になる」「バックグラウンドスレッドの未処理例外でプロセスが即終了する」という,現場では原因の分からない障害として現れる.同根なので個別に塞ぐと穴が残ると判断した. + - 設計判断: 撮影ボタンの再有効化は**保存スレッドの最終行が唯一の実行者**とし(保存中の再入を封じる従来の不変条件),保存スレッド未起動の経路だけ UI 層が復帰させる.UI 層は下位層の状態を読み取り専用プロパティで問い合わせる形にし,下位層から UI を呼ばない原則を維持した. + - 失敗パターンの圧縮: ① 例外を捕まえた UI 層で即座に再有効化 → モーダル表示中もメッセージがポンプされるため保存中にボタンが押せ,次の撮影の `Join` と保存スレッドの `Invoke` が相互待機してアプリが完全ハング → ② 保存スレッド起動後は UI 層が有効化しない方式へ.③ さらに「起動済みフラグ」のリセットが `Shot` 内だったため,保存フォルダ作成失敗など `Shot` 到達前の例外では前回の値が居座り 2 回目以降が固着 → ④ スレッド生存判定との併用+撮影要求の先頭でのリセットで解消. + - 保存スレッドの中断時は未消費の `Mat` を必ず解放する.打ち切り上限は総経過時間ではなく無通信時間にした(総経過だと長時間の連続撮影で正常時に誤爆し,逆にリークと撮影中のボタン有効化を招くため). + - 後段: 白板撮影(WHITEBOARD 限定)が通常の撮影ボタンと排他になっていない点は未対応.再校正機能を追加する場合は「撮影ボタンは保存完了まで有効化されない」前提が崩れ,上記①のハングが復活し得るため再入制御の設計から見直すこと(コードのコメントにも明記済み).