diff --git a/TIASshot/Cameras/CameraBase.cs b/TIASshot/Cameras/CameraBase.cs index 62a08a7..0e54a64 100644 --- a/TIASshot/Cameras/CameraBase.cs +++ b/TIASshot/Cameras/CameraBase.cs @@ -115,11 +115,22 @@ // 今回の撮影セッションで SaveThread を起動したか(IsSaveThreadStarted で公開する). // 他の 3 つより早く(Shot に入る前に)落とす必要があるため BeginShotRequest でもリセットする. private bool _saveThreadStarted = false; + // 今回の撮影要求の撮影間隔(ms)(単発は 0).StartSaveThread が保存キュー解放待ちの上限の算出に使う. + // BeginShotRequest でリセットし ShotMulti で設定する(Shot と同一の UI スレッド内で完結する). + private int _shotIntervalMs = 0; + + // 直近に通知した校正失敗のメッセージ(同じ失敗を毎フレーム通知しないため.校正完了でクリアする). + // カメラのコールバックスレッド(ProcessCalibrationFrame)だけが読み書きする. + private string _lastCalibrationError = null; + // 直近の校正失敗の理由(例外メッセージ).校正完了まで,チャート検出の表示を + // 「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替えるために使う + // (FormatChartDetectionMessage)._lastCalibrationError と同じスレッドで同時に設定・クリアする. + private string _lastCalibrationFailureReason = null; // SaveThread の起動完了待ちタイムアウト(通常は即座に完了する.無限待機の回避が目的) private static readonly TimeSpan SaveThreadReadyTimeout = TimeSpan.FromSeconds(5); - // 保存キュー解放待ちの無通信タイムアウト - // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険) + // 保存キュー解放待ちの無通信タイムアウトの下限 + // (撮影も CompleteAdding も来ない異常時にスレッドを残さないための保険.実際の上限は GetDrainIdleTimeout) private static readonly TimeSpan DrainIdleTimeout = TimeSpan.FromSeconds(30); // 保存キュー解放時の 1 回あたりの取り出し待ち時間(ms)(無通信時間の判定粒度) private const int DrainPollMs = 100; @@ -144,6 +155,12 @@ /// public bool IsSaveThreadStarted => _saveThreadStarted && _saveThread != null && _saveThread.IsAlive; + /// + /// 画像保存スレッド(SaveThread)が生存しているか(今回のセッションかどうかは問わない). + /// アプリを閉じてよいかの判定(Form1.CanClose)に使う.保存中に閉じると未保存の画像が失われる. + /// + public bool IsSaving => _saveThread != null && _saveThread.IsAlive; + // プライベートメンバ readonly float UpdateRate; @@ -162,7 +179,11 @@ var tccSrgb = IoUtil.LoadMatFromCsv(Config.GetString("File/TccSrgbRef")); var tccXyz = IoUtil.LoadMatFromCsv(Config.GetString("File/TccXyzRef")); _colorCorrector = new ColorCorrector(tccSrgb, tccXyz); - _chartDetector = new ChartDetector(_form.ShowMessage); + // チャート検出の表示は,直前の校正失敗があれば失敗の表示に差し替える(失敗を上書きして気づけなくしない). + // ChartDetector はカメラのコールバックスレッド(ProcessCalibrationFrame)からだけ呼ばれるため, + // 同じスレッドで読み書きする _lastCalibrationFailureReason をロック無しで読んでよい. + _chartDetector = new ChartDetector( + msg => _form.ShowMessage(FormatChartDetectionMessage(_lastCalibrationFailureReason, msg))); } /// @@ -207,7 +228,8 @@ SetInfo("データ名", _form.GetDataName()); SetInfo("撮影枚数", $"{_form.GetNumMultiShots()}"); SetInfo("撮影間隔(ms)", $"{_form.GetMultiShotsInterval()}"); - Shot(_form.GetNumMultiShots(), _form.GetMultiShotsInterval()); + _shotIntervalMs = _form.GetMultiShotsInterval(); + Shot(_form.GetNumMultiShots(), _shotIntervalMs); FinishShot("Sound/MultiShotDone"); } @@ -220,6 +242,7 @@ /// private void BeginShotRequest() { _saveThreadStarted = false; + _shotIntervalMs = 0; // 単発撮影の間隔(ShotMulti は Shot の直前に設定し直す) } /// @@ -299,7 +322,8 @@ /// finally では未消費の Mat を解放し,撮影ボタンを必ず再有効化する(UI 固着の防止). /// /// - protected void SaveThread(int numImages) { + /// 保存キュー解放待ちの無通信時間の上限(GetDrainIdleTimeout) + protected void SaveThread(int numImages, TimeSpan drainIdleTimeout) { // 消費対象は起動時点の _shots に固定する(次回撮影が _shots を差し替えても影響を受けない). var shots = _shots; @@ -333,7 +357,7 @@ // 【重要】順序は DrainShots → ShowError → EnableShots(true) を維持すること. // モーダル表示中は撮影ボタンが無効のままなので Shot への再入(=Join)が起きず, // Invoke のデッドロックを避けられる. - DrainShots(shots); + DrainShots(shots, drainIdleTimeout); if (saveError != null) { NotifySaveFailed(saveError); } @@ -378,13 +402,15 @@ /// 【重要】上限は「総経過時間」ではなく「無通信時間」で判定する.撮影枚数・間隔は UI から /// 任意に設定できるため(例: 60 枚 × 1000ms),総経過時間で打ち切ると正常な長時間連写の /// 途中で誤って待機を終え,未消費 Mat のリークと撮影中の EnableShots(true) を招く. + /// 無通信時間の上限は撮影間隔に応じて決める(GetDrainIdleTimeout.撮影間隔が長いと 1 枚ごとの無通信が長くなる). /// /// 対象の保存キュー - private void DrainShots(BlockingCollection shots) { + /// 無通信時間の上限 + private void DrainShots(BlockingCollection shots, TimeSpan idleTimeout) { var idle = Stopwatch.StartNew(); try { while (!shots.IsCompleted) { - if (idle.Elapsed > DrainIdleTimeout) { + if (idle.Elapsed > idleTimeout) { Debug.WriteLine("警告: 保存キューの解放待機がタイムアウトしました"); break; } @@ -401,34 +427,108 @@ } /// + /// 保存キュー解放待ち(DrainShots)の無通信時間の上限を求める. + /// 「30 秒」と「撮影間隔の 2 倍」の大きい方とする(撮影間隔が 30 秒を超える連写でも, + /// 次の 1 枚を待っている間に打ち切らないため).単発撮影・間隔 0 以下は 30 秒. + /// + /// 撮影間隔(ms).単発撮影は 0 + /// 無通信時間の上限 + internal static TimeSpan GetDrainIdleTimeout(int intervalMs) { + var byInterval = TimeSpan.FromMilliseconds(2.0 * intervalMs); + return byInterval > DrainIdleTimeout ? byInterval : DrainIdleTimeout; + } + + /// /// 校正フレーム処理の共通骨格(テンプレートメソッド) /// imgt が用意された後の校正/検出フローを担う. /// ゲイン反映(ApplyWhiteBalance)・収束時ログ出力(LogCalibrationConverged)・ /// 校正情報記録(RecordCalibrationInfo)は派生クラスに委譲する. /// 呼び出し順序は元コードに合わせ「LogCalibrationConverged → CalcTcc → RecordCalibrationInfo」. + /// カメラのコールバックから呼ばれるため,ここを例外境界とする(例外をコールバックへ伝えると + /// SDK のコールバックスレッドやプレビューが止まる).校正中の例外は HandleCalibrationFailure で + /// 通知し,チャート検出からやり直せる状態へ戻す. /// /// 処理対象 Mat(前処理済み) protected void ProcessCalibrationFrame(Mat imgt) { - if (_calibrating > 0) { - var whitePatch = Cv2.Mean(imgt, _chartMasks[12]); - Debug.WriteLine($"White patch R {whitePatch.Val2:.00} G {whitePatch.Val1:.00} B {whitePatch.Val0:.00}"); - if (_calibrating % Config.GetInt("Calib/UpdateInterval") == 0) { - ApplyWhiteBalance(whitePatch); - } - _calibrating--; - if (_calibrating == 0) { - LogCalibrationConverged(); - CalcTcc(imgt); - RecordCalibrationInfo(imgt); + try { + if (_calibrating > 0) { + var whitePatch = Cv2.Mean(imgt, _chartMasks[12]); + Debug.WriteLine($"White patch R {whitePatch.Val2:.00} G {whitePatch.Val1:.00} B {whitePatch.Val0:.00}"); + if (_calibrating % Config.GetInt("Calib/UpdateInterval") == 0) { + ApplyWhiteBalance(whitePatch); + } + _calibrating--; + if (_calibrating == 0) { + LogCalibrationConverged(); + CalcTcc(imgt); + RecordCalibrationInfo(imgt); #if MEMMONITOR - PreviewProfiler.MarkEvent("CALIB_END"); + PreviewProfiler.MarkEvent("CALIB_END"); #endif + } } + + if (!_calibrated && _calibrating == 0) { + DetectChart(imgt); + } + } catch (Exception ex) { + HandleCalibrationFailure(ex); + } + } + + /// + /// 校正中の例外の後始末(ProcessCalibrationFrame の例外境界から呼ぶ). + /// 未校正・校正フレーム数 0・チャートマスク解放の状態へ戻し,次のフレームからチャート検出をやり直させる. + /// 変換行列算出の後(RecordCalibrationInfo 等)で失敗して撮影ボタンを有効化済みの場合は無効に戻す. + /// 通知は「校正に失敗しました(理由)」とし,直前に通知した内容と同じなら繰り返さない + /// (設定不備等で毎フレーム同じ例外が起きる場合に通知とログが毎フレーム出続けないため). + /// + /// 校正中に発生した例外 + private void HandleCalibrationFailure(Exception ex) { + var wasCalibrated = _calibrated; + _calibrated = false; + _calibrating = 0; + ReleaseChartMasks(); + if (wasCalibrated) { + _form.EnableShots(false); } - if (!_calibrated && _calibrating == 0) { - DetectChart(imgt); - } + var message = FormatCalibrationFailure(ex); + _lastCalibrationFailureReason = ex.Message; // 校正完了までチャート検出の表示を差し替える + if (!ShouldNotifyCalibrationFailure(_lastCalibrationError, message)) return; + _lastCalibrationError = message; + Debug.WriteLine($"校正に失敗しました: {ex}"); + _form.ShowMessage(message); + } + + /// 校正失敗の通知文(理由として例外メッセージを含める) + /// 校正中に発生した例外 + internal static string FormatCalibrationFailure(Exception ex) { + return $"校正に失敗しました({ex.Message}).チャートの検出からやり直します"; + } + + /// + /// チャート検出(ChartDetector)の表示文を決める.直前の校正失敗が無ければ検出側の表示をそのまま返し, + /// あれば次に校正が完了するまで「校正に失敗しました(理由).チャートの検出からやり直しています」に差し替える + /// (検出中の表示が毎フレーム失敗の通知を上書きし,恒常的な失敗に気づけなくなるのを防ぐ). + /// ただしチャートが上下逆の表示は設置し直しの案内のため差し替えない. + /// + /// 直前の校正失敗の理由(失敗が無い・校正完了後は null) + /// ChartDetector が出そうとした表示文 + /// 実際に表示する文 + internal static string FormatChartDetectionMessage(string lastFailureReason, string detectionMessage) { + if (lastFailureReason == null) return detectionMessage; + if (detectionMessage == ChartDetector.UpsideDownMessage) return detectionMessage; + return $"校正に失敗しました({lastFailureReason}).チャートの検出からやり直しています"; + } + + /// + /// 校正失敗を通知するか(直前に通知した内容と同じなら通知しない). + /// + /// 直前に通知した校正失敗の通知文(未通知・校正完了後は null) + /// 今回の通知文 + internal static bool ShouldNotifyCalibrationFailure(string lastNotified, string message) { + return !string.Equals(lastNotified, message, StringComparison.Ordinal); } /// @@ -489,7 +589,8 @@ // ManualResetEventSlim をリセットして SaveThread の起動完了を待機できるようにする _saveThreadReady.Reset(); - var thread = new Thread(() => SaveThread(numImages)); + var drainIdleTimeout = GetDrainIdleTimeout(_shotIntervalMs); + var thread = new Thread(() => SaveThread(numImages, drainIdleTimeout)); _saveThread = thread; // 参照を保持(次回の完了待機用) thread.Start(); // 以降 EnableShots(true) は SaveThread が行う(Form1 の例外境界は再有効化しない) @@ -666,6 +767,7 @@ /// protected void CalcTcc(Mat img) { + bool imagesSaved; // 変換行列の計算(tccRgb・imgRois はここで所有し,使用後に確実に解放する) using (var tccRgb = GetTccRgb(img)) using (var imgRois = GetTccRoisImage(img)) { @@ -673,17 +775,29 @@ // データ保存 SetSaveFolder("校正"); SetInfo("校正データ", _saveFolder); - IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img); - IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois); + // 校正画像は記録用であり,保存に失敗しても変換行列(メモリ上)は有効なため校正は続ける. + // Cv2.ImWrite は失敗を false で返すだけなので(失敗のログは IoUtil.SaveImage が出す), + // 戻り値を見て完了表示で警告する. + var tccSaved = IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccImage")), img); + var roisSaved = IoUtil.SaveImage(IoUtil.CombineSaveFilePath(_saveFolder, Config.GetString("File/TccRoisImage")), imgRois); + imagesSaved = tccSaved && roisSaved; // 変換行列の計算・保存は ColorCorrector に委譲する(tccRgb の所有権は渡さない) _colorCorrector.CalcTcc(tccRgb, _saveFolder, IoUtil.SaveMatToCsv); } IoUtil.PlaySound(Config.GetString("Sound/CalibDone")); - _form.ShowMessage("自動校正完了"); + _form.ShowMessage(FormatCalibrationDone(imagesSaved)); _form.EnableShots(); _calibrated = true; + _lastCalibrationError = null; // 次に失敗したときは改めて通知する + _lastCalibrationFailureReason = null; // チャート検出の表示を従来どおりに戻す + } + + /// 自動校正完了の表示文(校正画像の保存に失敗した場合は警告を添える) + /// 校正画像(TccImage・TccRoisImage)を両方とも保存できたか + internal static string FormatCalibrationDone(bool imagesSaved) { + return imagesSaved ? "自動校正完了" : "自動校正完了(校正画像の保存に失敗)"; } /// diff --git a/TIASshot/ChartDetection/ChartDetector.cs b/TIASshot/ChartDetection/ChartDetector.cs index eb4f2c1..3a80c03 100644 --- a/TIASshot/ChartDetection/ChartDetector.cs +++ b/TIASshot/ChartDetection/ChartDetector.cs @@ -25,6 +25,9 @@ // 状態表示(呼び出し側が提供) readonly Action _showMessage; + /// チャートが上下逆のときの表示(設置し直しの案内.CameraBase が校正失敗後も差し替えずに出す) + internal const string UpsideDownMessage = "舌診チャートが上下逆方向です"; + public ChartDetector(Action showMessage) { _showMessage = showMessage; // ArucoDetector は辞書を内部にコピーして保持するため,元の Dictionary はここで解放してよい @@ -65,7 +68,7 @@ } if (ptsPict.Count < 8) return null; if (y40 > y41) { - _showMessage("舌診チャートが上下逆方向です"); + _showMessage(UpsideDownMessage); return null; } diff --git a/TIASshot/LightSource.cs b/TIASshot/LightSource.cs index 910c910..f72725d 100644 --- a/TIASshot/LightSource.cs +++ b/TIASshot/LightSource.cs @@ -60,6 +60,25 @@ _serial.Write(str); } + /// + /// 光源を消してポートを閉じる(アプリ終了時に呼ぶ).開いていなければ何もしない. + /// 終了処理を止めないよう,消灯・クローズの失敗は捕捉してログのみ残す + /// (消灯に失敗してもポートは閉じる). + /// + public void Close() { + if (!_serial.IsOpen) return; + try { + TurnOff(); + } catch (Exception ex) { + Debug.WriteLine($"光源の消灯に失敗しました: {ex}"); + } + try { + _serial.Close(); + } catch (Exception ex) { + Debug.WriteLine($"光源のポートを閉じられませんでした: {ex}"); + } + } + public void SetLight() { byte[] buf = new byte[] { 0x02, 0x44, 0x41, 0x58, 0x32, 0x3A, 0x44, 0x45, 0x46, 0x20, 0x33, 0x30, 0x37, 0x32, 0x03, 0x30, 0x34, 0x04 }; diff --git a/TIASshot/SelfTest/SelfTestCamera.cs b/TIASshot/SelfTest/SelfTestCamera.cs index 062b302..3d311e0 100644 --- a/TIASshot/SelfTest/SelfTestCamera.cs +++ b/TIASshot/SelfTest/SelfTestCamera.cs @@ -31,6 +31,8 @@ private double _gainR = 1.0; private Thread _previewThread; + // 切断時にプレビュースレッドの終了を待つ上限 + private static readonly TimeSpan DisconnectJoinTimeout = TimeSpan.FromSeconds(3); private volatile bool _stopPreview; private int _frameIndex; private int _framesDelivered; @@ -79,12 +81,20 @@ } /// - /// 切断.プレビュースレッドに停止を要求し,二重バッファの Bitmap を解放する. - /// 【前提】プレビュースレッドは UI スレッドへの Invoke で待つことがあるため, - /// 呼び出し元は UI スレッドのメッセージを回しながら IsPreviewRunning が false になるのを待ってから呼ぶこと. + /// 切断.プレビュースレッドに停止を要求して終了を待ち(実カメラの SDK がストリーム停止で + /// コールバックの終了を待つのと同じ),二重バッファの Bitmap とチャートマスクを解放する. + /// プレビュースレッドから UI スレッドへの呼び出し(ShowMessage / EnableShots)は待たない投稿のため, + /// UI スレッドから呼んでも相互待機にならない.アプリを閉じる処理(Form1_FormClosing)からも呼ばれる. + /// 待機には上限を設け,超えた場合はログを残して解放へ進む. /// public override void Disconnect() { _stopPreview = true; + var thread = _previewThread; + if (thread != null && thread != Thread.CurrentThread && thread.IsAlive) { + if (!thread.Join(DisconnectJoinTimeout)) { + Debug.WriteLine("警告: SelfTestCamera の切断でプレビュースレッドの終了待ちがタイムアウトしました"); + } + } for (var i = 0; i < _bmps.Length; i++) { _bmps[i]?.Dispose(); _bmps[i] = null; diff --git a/TIASshot/UI/Form1.SelfTest.cs b/TIASshot/UI/Form1.SelfTest.cs index c4803a2..b551b23 100644 --- a/TIASshot/UI/Form1.SelfTest.cs +++ b/TIASshot/UI/Form1.SelfTest.cs @@ -122,6 +122,50 @@ txtMultiShotsInterval.Text = intervalMs; } + // 閉じるときの後始末(ShutdownOnClose)が通過した段階の記録(古い順._selfTestLogLock で保護) + private readonly List _selfTestCloseSteps = new List(); + + /// + /// 閉じるときの後始末の対象にするカメラを設定する(Form1_Load の代わり.ヘッドレス Form1 は _camera を持たないため). + /// 閉じてよいかの判定(保存中か)と,後始末でのカメラ切断に使われる. + /// + internal void SelfTestAttachCamera(CameraBase camera) { + _camera = camera; + } + + /// + /// ユーザーが閉じようとしたときの処理(Form1_FormClosing)を呼ぶ(UI スレッドで呼ぶこと). + /// ヘッドレス Form1 では FormClosing イベントが接続されていない(InitializeComponent を呼ばない)ため,この口から呼ぶ. + /// 閉じると決まった場合は後始末(閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源)まで行う. + /// フォームの破棄(Dispose)は行わない(ハーネスの後始末に任せる). + /// + /// 閉じると決まったら true,取り消されたら(保存中)false + internal bool SelfTestRequestClose() { + var e = new FormClosingEventArgs(CloseReason.UserClosing, false); + Form1_FormClosing(this, e); + return !e.Cancel; + } + + /// 閉じ始めたか(以降 ShowMessage / EnableShots は何もしない) + internal bool SelfTestIsClosing => _isClosing; + + /// 本モニタの再描画タイマーが動作中か(閉じるときの後始末で停止される) + internal bool SelfTestRefreshTimerRunning => _mainRefreshTimer.Enabled; + + /// + /// 閉じるときの後始末が通過した段階(古い順のコピー."closing" → "timer" → "camera" → "light"). + /// カメラ切断が例外になった場合は "camera" が抜ける. + /// + internal IReadOnlyList SelfTestCloseSteps { + get { lock (_selfTestLogLock) return _selfTestCloseSteps.ToArray(); } + } + + partial void OnCloseStep(string step) { + lock (_selfTestLogLock) { + _selfTestCloseSteps.Add(step); + } + } + /// ShowError のヘッドレス版(モーダルダイアログを出すと無人実行が止まるため記録のみ行う) private void SelfTestRecordError(string msg) { Debug.WriteLine($"[SELFTEST] ShowError: {msg}"); diff --git a/TIASshot/UI/Form1.cs b/TIASshot/UI/Form1.cs index ba043a2..3667e62 100644 --- a/TIASshot/UI/Form1.cs +++ b/TIASshot/UI/Form1.cs @@ -35,6 +35,15 @@ private const int MainRefreshIntervalMs = 33; // ≒30fps(PreviewMonitor と同じ値) private readonly System.Windows.Forms.Timer _mainRefreshTimer = new System.Windows.Forms.Timer(); + // アプリを閉じ始めたか(Form1_FormClosing で閉じると決めた時点で立てる). + // 立った後は,カメラのコールバック・保存スレッド由来の UI 呼び出し(ShowMessage / EnableShots)を何もしない. + // 切断(SDK のストリーム停止)がコールバックの終了を待つ間に,コールバック側が UI スレッドを待つと + // 相互待機でハングするため.別スレッドから読むため volatile とする. + private volatile bool _isClosing = false; + + /// 保存中に閉じようとしたときの表示 + internal const string SavingOnCloseMessage = "保存中です.保存完了後に閉じてください"; + /// /// コンストラクタ @@ -121,12 +130,51 @@ /// /// private void Form1_FormClosing(object sender, FormClosingEventArgs e) { - _mainRefreshTimer.Stop(); - if (_camera == null) return; - _camera.Disconnect(); + if (!CanClose(_camera != null && _camera.IsSaving)) { + // 保存中に閉じると未保存の画像が失われるため,閉じるのを取り消して知らせる + e.Cancel = true; + ShowMessage(SavingOnCloseMessage); + return; + } + ShutdownOnClose(); } /// + /// アプリを閉じてよいか(保存中は閉じない). + /// + /// 画像保存スレッドが動作中か(CameraBase.IsSaving) + /// 閉じてよければ true + internal static bool CanClose(bool isSaving) { + return !isSaving; + } + + /// + /// 閉じるときの後始末(Form1_FormClosing で閉じると決めた後に呼ぶ). + /// 【重要】順序は「閉じ始めのフラグ → タイマー停止 → カメラ切断 → 光源を消してポートを閉じる」を守ること. + /// フラグを先に立てることで,切断がコールバックの終了を待つ間にコールバック側が UI 呼び出しで + /// UI スレッドを待つことがなくなる(ハングの防止).カメラの切断が例外になっても光源は閉じる. + /// + private void ShutdownOnClose() { + _isClosing = true; + OnCloseStep("closing"); + _mainRefreshTimer.Stop(); + OnCloseStep("timer"); + try { + _camera?.Disconnect(); + OnCloseStep("camera"); + } finally { + _light.Close(); + OnCloseStep("light"); + } + } + + /// + /// 閉じるときの後始末の各段階を通過したことの記録(SELFTEST でのみ実装.Release では呼び出しごと消える). + /// + /// 段階名(closing / timer / camera / light) + partial void OnCloseStep(string step); + + /// /// 画像撮影1枚 /// /// @@ -271,8 +319,12 @@ /// /// public void ShowMessage(string msg) { + // UI スレッド外(カメラのコールバック・保存スレッド)からの呼び出しは UI スレッドの処理を待たずに投稿する + // (BeginInvoke.撮影中で UI スレッドが塞がっていても保存が進み,画像がキューに溜まり続けないため). + // 閉じ始めた後・ハンドルが無い/破棄済みのときは何もしない. + if (!CanUpdateUi()) return; if (InvokeRequired) { - Invoke((MethodInvoker)delegate { ShowMessage(msg); }); + PostToUi(delegate { ShowMessage(msg); }); return; } #if SELFTEST @@ -343,8 +395,11 @@ /// /// public void EnableShots(bool enable = true) { + // UI スレッド外からの呼び出しは ShowMessage と同じく待たずに投稿する(同じスレッドから投稿した + // ShowMessage・ShowError との順序は保たれる).閉じ始めた後・ハンドルが無い/破棄済みのときは何もしない. + if (!CanUpdateUi()) return; if (InvokeRequired) { - Invoke((MethodInvoker)delegate { EnableShots(enable); }); + PostToUi(delegate { EnableShots(enable); }); return; } btnShotOne.Enabled = enable; @@ -352,6 +407,29 @@ } /// + /// 通知系の UI 呼び出し(ShowMessage / EnableShots)を行ってよいか. + /// 閉じ始めた後(_isClosing)・破棄中/破棄済み・ハンドル未生成のときは false. + /// + private bool CanUpdateUi() { + return !_isClosing && !IsDisposed && !Disposing && IsHandleCreated; + } + + /// + /// UI スレッドへ処理を投稿する(完了を待たない). + /// 判定(CanUpdateUi)の直後にハンドルが破棄されると BeginInvoke が例外を投げるため, + /// 捕捉してログのみ残す(コールバック・保存スレッドへ例外を伝えない). + /// + /// UI スレッドで実行する処理 + private void PostToUi(MethodInvoker action) { + try { + BeginInvoke(action); + } catch (InvalidOperationException ex) { + // ObjectDisposedException(InvalidOperationException の派生)を含む + Debug.WriteLine($"UI スレッドへの投稿に失敗しました(フォームの終了中): {ex.Message}"); + } + } + + /// /// データフォルダを開く /// ///