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 d4709ad..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,13 +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 を二重に呼ばないための判定に使う. + private bool _shotLoopFinished = false; + // 今回の撮影セッションで SaveThread を起動したか(IsSaveThreadStarted で公開する). + // 他の 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)が起動済みで,かつまだ生存しているか. + /// 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 && _saveThread != null && _saveThread.IsAlive; // プライベートメンバ readonly float UpdateRate; @@ -118,6 +190,7 @@ /// 画像撮影1枚 /// public void ShotOne() { + BeginShotRequest(); SetSaveFolder(_form.GetDataName()); SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", "1"); @@ -129,6 +202,7 @@ /// 複数画像撮影 /// public void ShotMulti() { + BeginShotRequest(); SetSaveFolder(_form.GetDataName()); SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}"); @@ -138,6 +212,17 @@ } /// + /// 撮影要求の受け付け開始(ShotOne / ShotMulti の先頭で,他の何よりも先に呼ぶこと). + /// 「今回のセッションで SaveThread を起動した」フラグを落とし,前回セッションの値が + /// Shot 到達前の例外(SetSaveFolder のフォルダ作成失敗等)に持ち越されないようにする. + /// _shotAborted 等は前回の SaveThread がまだ読み得るためここではクリアせず, + /// 前回 SaveThread の Join 後(PrepareShotSession)に行う. + /// + private void BeginShotRequest() { + _saveThreadStarted = false; + } + + /// /// 撮影完了後の後処理(Shot から戻った直後に呼ぶこと). /// 正常終了時は完了音を鳴らして「撮影終了」を表示する. /// 中止時は完了音・完了メッセージを出さず(SaveThread が中止を表示する),中止理由を通知する. @@ -192,29 +277,112 @@ } /// - /// 画像保存スレッド処理 + /// 画像保存スレッド処理. + /// バックグラウンドスレッドの未処理例外は .NET Framework 4.8 ではプロセスを即終了させるため, + /// ここに例外境界を置き,保存失敗(ディスク満杯・パス不正・OpenCV 例外等)でもアプリを継続させる. + /// ユーザー通知は Form1(ShowError)へ委譲し,本クラスからは MessageBox を直接呼ばない. + /// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止). /// /// protected void SaveThread(int numImages) { + // 消費対象は起動時点の _shots に固定する(次回撮影が _shots を差し替えても影響を受けない). + var shots = _shots; + // スレッド起動完了を通知(producer が待機している) _saveThreadReady.Set(); int saveCount = 0; - foreach (var img in _shots.GetConsumingEnumerable()) { - using (img) { - SaveImages(img, saveCount); + string saveError = null; + try { + foreach (var img in shots.GetConsumingEnumerable()) { + using (img) { + SaveImages(img, saveCount); + } + saveCount++; + _form.ShowMessage($"画像{saveCount}/{numImages}枚目保存"); } - saveCount++; - _form.ShowMessage($"画像{saveCount}/{numImages}枚目保存"); + // 撮影が中止された場合は「全て保存完了」と表示すると誤解を与えるため, + // 取得できた枚数のみ保存した旨を表示する. + if (_shotAborted) { + _form.ShowMessage($"撮影中止:取得済みの{saveCount}枚のみ保存しました"); + } else { + _form.ShowMessage("全ての画像保存完了"); + } + } catch (Exception ex) { + // 撮影自体の失敗(Form1 の例外境界が通知する)とは別事象のため,保存失敗として通知する. + // モーダル通知(ShowError)は UI スレッドへの Invoke でブロックするため,未消費 Mat の + // 解放を先に済ませるべく,ここではメッセージを保持するだけにして finally で通知する. + Debug.WriteLine($"画像保存に失敗しました({saveCount + 1}枚目): {ex}"); + saveError = ex.Message; + } finally { + // 【重要】順序は DrainShots → ShowError → EnableShots(true) を維持すること. + // モーダル表示中は撮影ボタンが無効のままなので Shot への再入(=Join)が起きず, + // Invoke のデッドロックを避けられる. + DrainShots(shots); + if (saveError != null) { + NotifySaveFailed(saveError); + } + RestoreShotButtons(); } - // 撮影が中止された場合は「全て保存完了」と表示すると誤解を与えるため, - // 取得できた枚数のみ保存した旨を表示する. - if (_shotAborted) { - _form.ShowMessage($"撮影中止:取得済みの{saveCount}枚のみ保存しました"); - } else { - _form.ShowMessage("全ての画像保存完了"); + } + + /// + /// 保存失敗をユーザーへ通知する(SaveThread の finally から呼ぶ). + /// フォーム破棄後(アプリ終了時に保存が残っている場合)の Control.Invoke は + /// ObjectDisposedException / InvalidOperationException を投げる.finally から例外が出ると + /// バックグラウンドスレッドの未処理例外となりプロセスが即終了するため個別に捕捉する. + /// + /// 保存失敗の理由(例外メッセージ) + private void NotifySaveFailed(string saveError) { + try { + _form.ShowError($"画像の保存に失敗しました.\r\n{saveError}"); + } catch (Exception ex) { + Debug.WriteLine($"保存失敗の通知に失敗しました: {ex}"); } - _form.EnableShots(true); + } + + /// + /// 撮影ボタンを再有効化する(SaveThread の finally から呼ぶ). + /// 再有効化は SaveThread 起動後の全経路で本スレッドの責務であり, + /// Form1 の例外境界は再有効化しない(IsSaveThreadStarted 参照). + /// NotifySaveFailed と同じ理由で,Invoke の失敗はログのみに留める. + /// + private void RestoreShotButtons() { + try { + _form.EnableShots(true); + } catch (Exception ex) { + Debug.WriteLine($"撮影ボタンの再有効化に失敗しました: {ex}"); + } + } + + /// + /// 保存キューに残った Mat を取り出して解放する(例外で保存ループを抜けた場合のリーク防止). + /// producer が撮影を続けている可能性があるため,CompleteAdding 済みかつ空になるまで待つ. + /// CompleteAdding には全経路で到達するが(RunShotLoop / FinalizeShotSession), + /// 万一到達しない異常時にスレッドが残らないよう待機時間の上限を設ける. + /// 【重要】上限は「総経過時間」ではなく「無通信時間」で判定する.撮影枚数・間隔は UI から + /// 任意に設定できるため(例: 60 枚 × 1000ms),総経過時間で打ち切ると正常な長時間連写の + /// 途中で誤って待機を終え,未消費 Mat のリークと撮影中の EnableShots(true) を招く. + /// + /// 対象の保存キュー + private void DrainShots(BlockingCollection shots) { + var idle = Stopwatch.StartNew(); + try { + while (!shots.IsCompleted) { + if (idle.Elapsed > DrainIdleTimeout) { + Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました"); + break; + } + if (shots.TryTake(out var remaining, DrainPollMs)) { + remaining?.Dispose(); + idle.Restart(); // producer が生きている間は待機を継続する + } + } + } catch (Exception ex) { + // 解放処理自体の失敗はユーザー操作で回復できないため,ログのみ残して継続する + // (ここで throw すると EnableShots(true) に到達できず UI が固着する). + Debug.WriteLine($"保存キューの解放に失敗しました: {ex}"); + } } /// @@ -249,6 +417,103 @@ } /// + /// 前回の撮影セッションの SaveThread が完了するまで待機する + /// (派生クラスの Shot の先頭で,PrepareShotSession より前に呼ぶこと). + /// 低スペック PC で保存と次の撮影が競合するのを防ぐ. + /// 【重要】撮影セッションの初期化(PrepareShotSession)は必ずこの待機の後に行う. + /// 先に _shotAborted をクリアすると,前回が中止で終わった直後の撮影で,まだ走っている + /// 前回の SaveThread が中止フラグを false と読み,「全ての画像保存完了」と誤表示する. + /// + protected void WaitPreviousSaveThread() { + if (_saveThread != null && _saveThread.IsAlive) { + _saveThread.Join(); + } + } + + /// + /// 撮影セッションの開始準備(派生クラスの Shot で,SaveThread 起動より前に呼ぶこと). + /// 中止状態と「撮影ループを完走したか」のフラグを初期化する. + /// RunShotLoop に到達しないまま例外で抜けた場合を FinalizeShotSession が検出できるようにする. + /// + protected void PrepareShotSession() { + _shotLoopFinished = false; + _saveThreadStarted = false; + _shotAborted = false; + _abortReason = null; + } + + /// + /// 保存キュー(_shots)を今回のセッション用に作り直す + /// (派生クラスの Shot で,SaveThread 起動より前に呼ぶこと). + /// 新しいインスタンスを作成してから古い BlockingCollection を破棄する + /// (順序を逆にすると,破棄と生成の間に例外が発生した場合に _shots が破棄済みインスタンスを + /// 指したままとなり,後段の CompleteAdding が ObjectDisposedException になる). + /// + 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; + } + + /// + /// 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 が呼び済みのため,ここでは呼ばない) + /// + private void FinalizeShotSession() { + if (_shotLoopFinished) return; // RunShotLoop が CompleteAdding 済み(二重呼び出し防止) + // 撮影枚数が揃っていないため中止扱いとし,SaveThread に「全て保存完了」と表示させない. + // 中止理由は設定しない(想定外例外は呼び出し元 Form1 の例外境界が通知する). + _shotAborted = true; + _shots.CompleteAdding(); + } + + /// /// 撮影ループの共通骨格(テンプレートメソッド) /// 撮影ログ(Info.csv)の準備と中止状態の管理を担い,ループ本体は CaptureShots に, /// フレーム取得(CaptureFrame)は派生クラスに委譲する. @@ -289,6 +554,8 @@ // 中止理由は EndShot 完了後(ShotOne/ShotMulti)に通知するため保持する. _abortReason = abortReason; _shots.CompleteAdding(); + // CompleteAdding 済みであることを記録し,FinalizeShotSession の二重呼び出しを防ぐ. + _shotLoopFinished = true; #if MEMMONITOR PreviewProfiler.MarkEvent("SHOT_END"); #endif diff --git a/TIASshot/Cameras/IScam.cs b/TIASshot/Cameras/IScam.cs index 9e125f2..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,57 +168,50 @@ } /// - /// 撮影 + /// 撮影(IScam 固有) + /// 呼び出し順序は CameraBase.Shot の規定に準じるが,BeginShot(Sink 切り替え)だけは + /// 捨てフレームの取得に必要なため try の内側で先に呼ぶ. /// - /// - /// + /// 撮影枚数 + /// 撮影間隔(ms) protected override void Shot(int numImages = 1, int interval = 0) { + WaitPreviousSaveThread(); + PrepareShotSession(); + + // Sink 切り替え以降は,どの経路でも撮影終了処理(EndShot)と CompleteAdding に到達させる. + // 捨てフレームの SnapSingle がタイムアウトする等で例外が出ると,Sink が _snapSink のままとなり + // ライブプレビューが停止したままになり,また SaveThread が終了通知を受け取れず永久にブロックする. + try { + BeginShot(); + ResetShotQueue(); + + _snapSinkRef.SnapSingle(SnapTimeout); // 最初のフレームを捨てる + + StartSaveThread(numImages); + WaitSaveThreadReady(); + + RunShotLoop(numImages, interval); + } finally { + EndShotSession(); + } + } + + /// + /// 撮影開始処理(IScam 固有): 撮影用の Sink(FrameSnapSink)へ切り替える + /// + protected override void BeginShot() { _ic.LiveStop(); _ic.Sink = _snapSink; _ic.LiveStart(); _snapSinkRef = _ic.Sink as FrameSnapSink; - - // 前回の SaveThread が完了するまで待機(低スペックPCでの競合回避) - if (_saveThread != null && _saveThread.IsAlive) { - _saveThread.Join(); - } - - // 古い BlockingCollection を破棄してから新しいインスタンスを作成 - _shots?.Dispose(); - _shots = new BlockingCollection(); - _snapSinkRef.SnapSingle(TimeSpan.FromSeconds(5)); // 最初のフレームを捨てる - - // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする - _saveThreadReady.Reset(); - - var thread = new Thread(() => SaveThread(numImages)); - _saveThread = thread; // 参照を保持(次回の完了待機用) - thread.Start(); - - // SaveThread が起動完了するまで待機(consumer 準備完了を保証) - // タイムアウト設定で無限待機を回避(通常は即座に完了するはず) - if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { - Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); - } - - RunShotLoop(numImages, interval); - - _ic.LiveStop(); - _ic.Sink = _queueSink; - _ic.LiveStart(); } /// - /// 撮影開始処理(IScam 固有): Shot 内でインライン実行済みのため空実装 - /// - protected override void BeginShot() { } - - /// /// 1 フレーム取得(IScam 固有) /// /// 撮影した 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); @@ -224,9 +220,14 @@ } /// - /// 撮影終了処理(IScam 固有): Shot 内でインライン実行済みのため空実装 + /// 撮影終了処理(IScam 固有): ライブプレビュー用の Sink(_queueSink)へ戻す. + /// 例外経路でも必ず実行し,プレビューが停止したままになるのを防ぐ. /// - protected override void EndShot() { } + protected override void EndShot() { + _ic.LiveStop(); + _ic.Sink = _queueSink; + _ic.LiveStart(); + } #if WHITEBOARD // --------------------------------------------------------------- diff --git a/TIASshot/Cameras/Lucam.cs b/TIASshot/Cameras/Lucam.cs index 21248d0..176c590 100644 --- a/TIASshot/Cameras/Lucam.cs +++ b/TIASshot/Cameras/Lucam.cs @@ -217,37 +217,31 @@ } /// - /// 撮影 + /// 撮影(Lucam 固有) + /// 呼び出し順序は CameraBase.Shot の規定どおり + /// (前回待機 → セッション初期化 → キュー作り直し → SaveThread 起動 → 撮影 → 終端処理). /// - /// - /// + /// 撮影枚数 + /// 撮影間隔(ms) protected override void Shot(int numImages=1, int interval=0) { SetSnapParam(); + WaitPreviousSaveThread(); + PrepareShotSession(); + ResetShotQueue(); + StartSaveThread(numImages); - // 前回の SaveThread が完了するまで待機(低スペックPCでの競合回避) - if (_saveThread != null && _saveThread.IsAlive) { - _saveThread.Join(); + // SaveThread 起動後は,どの経路でも撮影終了処理(EndShot)と CompleteAdding に到達させる. + // BeginShot(LucamEnableFastFrames は失敗しても false を返すだけで例外を投げないが, + // 直後のバッファ確保 new byte[...] は OutOfMemoryException を投げ得る)等で例外が出ると, + // EndShot 未実行のまま LucamDisableFastFrames が呼ばれず高速フレームモードが残り, + // また SaveThread が終了通知を受け取れず永久にブロックしてしまう. + try { + WaitSaveThreadReady(); + BeginShot(); + RunShotLoop(numImages, interval); + } finally { + EndShotSession(); } - - // 古い BlockingCollection を破棄してから新しいインスタンスを作成 - _shots?.Dispose(); - _shots = new BlockingCollection(); - - // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする - _saveThreadReady.Reset(); - - var thread = new Thread(() => SaveThread(numImages)); - _saveThread = thread; // 参照を保持(次回の完了待機用) - thread.Start(); - - // SaveThread が起動完了するまで待機(consumer 準備完了を保証) - if (!_saveThreadReady.Wait(TimeSpan.FromSeconds(5))) { - Debug.WriteLine("警告: SaveThread の起動完了待機がタイムアウトしました"); - } - - BeginShot(); - RunShotLoop(numImages, interval); - EndShot(); } /// diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index 01d811c..a742849 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -132,8 +132,7 @@ /// /// private void btnShot1_Click(object sender, EventArgs e) { - EnableShots(false); - _camera.ShotOne(); + ExecuteShot(() => _camera.ShotOne()); } /// @@ -142,8 +141,37 @@ /// /// private void btnShotMulti_Click(object sender, EventArgs e) { + ExecuteShot(() => _camera.ShotMulti()); + } + + /// + /// 撮影の例外境界.撮影ボタンを無効化して撮影処理を実行し, + /// 想定外の例外が発生した場合はユーザーに通知する. + /// 例外を捕捉しないと WinForms の未処理例外ダイアログになるうえ, + /// SaveThread を起動する前に失敗した場合は EnableShots(true) に誰も到達せず, + /// 撮影ボタンが無効のまま固着してアプリの再起動が必要になる. + /// 【重要】SaveThread 起動後(IsSaveThreadStarted == true)は再有効化してはならない. + /// 撮影が例外で終わっても保存はまだ続いており,ここで先に有効化すると保存中に次の撮影へ + /// 再入できてしまい,UI スレッドの _saveThread.Join() と SaveThread 側の Control.Invoke が + /// 相互待機してアプリがハングする.この経路の再有効化は SaveThread の finally が必ず行う + /// (CompleteAdding には全経路で到達するため SaveThread は必ず完走する). + /// 撮影の中止通知(CaptureFailedException 由来)は例外ではなく CameraBase 側が + /// 撮影終了処理の完了後に行うため,ここでは扱わない. + /// + /// 撮影処理(ShotOne / ShotMulti) + private void ExecuteShot(Action shot) { EnableShots(false); - _camera.ShotMulti(); + try { + shot(); + } catch (Exception ex) { + Debug.WriteLine($"撮影に失敗しました: {ex}"); + // カメラの状態復帰(EndShot)と SaveThread の終了通知は Shot 内の finally で完了済み. + // SaveThread 未起動の経路だけをここで復帰させる(起動済みなら SaveThread に任せる). + 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 限定)が通常の撮影ボタンと排他になっていない点は未対応.再校正機能を追加する場合は「撮影ボタンは保存完了まで有効化されない」前提が崩れ,上記①のハングが復活し得るため再入制御の設計から見直すこと(コードのコメントにも明記済み).