From 46adbfd6f68a388b8c33cd2b38ff52004cb4ceb2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 5 Nov 2026 12:49:47 -0500 Subject: [PATCH] fix(ui): refresh the drawing list on the UI thread, never from the timer thread EditNestForm's debounce timer is a System.Timers.Timer whose Elapsed ran on a pool thread, checked drawingListBox1.IsHandleCreated, then called drawingListBox1.Invoke. Invoke reads Handle, so a handle destroyed between the check and the call was re-created on the pool thread (the cross-thread check is off outside the debugger) while the UI thread was disposing the window, which threw "Dispose() cannot be called while doing CreateHandle()". The timer starts enabled at 50 ms, so short WinForms tests such as LedgerCodeRecheckTests.NoLedgerOrNoCodes_ChecksNothing hit it intermittently on CI. Elapsed now posts to the window's UI synchronization context and the refresh checks IsDisposed there; the timer is disposed with the form. Co-Authored-By: Claude Opus 5.5 --- OpenNest/Forms/EditNestForm.Designer.cs | 2 ++ OpenNest/Forms/EditNestForm.cs | 25 +++++++++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/OpenNest/Forms/EditNestForm.Designer.cs b/OpenNest/Forms/EditNestForm.Designer.cs index 78986040..a5e91441 100644 --- a/OpenNest/Forms/EditNestForm.Designer.cs +++ b/OpenNest/Forms/EditNestForm.Designer.cs @@ -19,6 +19,8 @@ // The simulator's lock disables the main window until it closes. if (disposing) cutSimulatorForm?.Close(); + if (disposing) + updateDrawingListTimer?.Dispose(); if (disposing && (components != null)) { components.Dispose(); diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 2a8db2c3..348c91a7 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -49,6 +49,12 @@ namespace OpenNest.Forms public readonly NestHoldStatus HoldStatus; private readonly Timer updateDrawingListTimer; + + // The window's UI thread. updateDrawingListTimer fires on a pool thread, which must + // never touch a control: Control.Invoke reads Handle, and a handle destroyed between + // the check and the call is then re-created on the pool thread while this window is + // being disposed ("Dispose() cannot be called while doing CreateHandle()"). + private readonly SynchronizationContext uiContext; private bool updatingPlateList; private bool addingToSet; private CutSimulatorForm cutSimulatorForm; @@ -457,6 +463,7 @@ namespace OpenNest.Forms Interval = 50, }; updateDrawingListTimer.Elapsed += drawingListUpdateTimer_Elapsed; + uiContext = SynchronizationContext.Current; Document = new Document { Nest = nest }; HoldStatus = new NestHoldStatus(Document); @@ -1790,14 +1797,13 @@ namespace OpenNest.Forms updateDrawingListTimer.Start(); } - private void drawingListUpdateTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) - { - if (!drawingListBox1.IsHandleCreated) - return; - - drawingListBox1.Invoke( - new MethodInvoker(() => + private void drawingListUpdateTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) => + uiContext?.Post(_ => { + // Runs on the UI thread, so this check cannot race the window's disposal. + if (IsDisposed || Disposing || !drawingListBox1.IsHandleCreated) + return; + if (hideNestedButton.Checked) { drawingListBox1.BeginUpdate(); @@ -1827,9 +1833,8 @@ namespace OpenNest.Forms // part-count and utilization columns stay current. The debounce // timer collapses a fill's many per-part events into one refresh. UpdatePlateList(); - }) - ); - } + }, + null); private void drawingListBox1_DoubleClick(object sender, EventArgs e) {