From bfb3df6a756222a3a10aec9c70be9e91ced85f00 Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:59:39 +0100 Subject: [PATCH 1/2] #2260 - Added CancellationToken to Calculate --- src/EPPlus/ExcelPackage.cs | 3 + src/EPPlus/ExcelWorkbook.cs | 31 ++++ .../FormulaParsing/CalculateExtensions.cs | 69 +++++++-- .../DependencyChain/RpnFormulaExecution.cs | 42 +++++ .../FormulaParsing/ExcelCalculationOption.cs | 23 +++ .../FormulaParsing/CancelCalculationTests.cs | 146 ++++++++++++++++++ 6 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs diff --git a/src/EPPlus/ExcelPackage.cs b/src/EPPlus/ExcelPackage.cs index 9df1801012..073a202a0b 100644 --- a/src/EPPlus/ExcelPackage.cs +++ b/src/EPPlus/ExcelPackage.cs @@ -954,6 +954,9 @@ public void Dispose() public void Save() { CheckNotDisposed(); +#if !NET35 + Workbook.ThrowIfCalculationCancelled(); +#endif try { if (_stream is MemoryStream && _stream.Length > 0) diff --git a/src/EPPlus/ExcelWorkbook.cs b/src/EPPlus/ExcelWorkbook.cs index 276d191569..58b592cbeb 100644 --- a/src/EPPlus/ExcelWorkbook.cs +++ b/src/EPPlus/ExcelWorkbook.cs @@ -402,6 +402,37 @@ private void GetSharedStrings() } + #region Calculation cancellation (poison flag) + + #if !NET35 + internal bool IsCalculationCancelled { get; private set; } + + internal void MarkCalculationCancelled() + { + IsCalculationCancelled = true; + } + + internal void ThrowIfCalculationCancelled() + { + if (IsCalculationCancelled) + { + throw new InvalidOperationException( + "This workbook has been left in an inconsistent state due to a cancelled " + + "calculation. The workbook must be disposed and cannot be used for further " + + "operations. Reload the workbook from the source to continue."); + } + } + + /// + /// Returns true if a calculation was cancelled, leaving the workbook in an inconsistent state. + /// A workbook in this state must be disposed — saving or recalculating is not permitted. + /// + public bool IsCalculationInconsistent => IsCalculationCancelled; + #endif + + #endregion + + internal void GetDefinedNames() { XmlNodeList nl = WorkbookXml.SelectNodes("//d:definedNames/d:definedName", NameSpaceManager); diff --git a/src/EPPlus/FormulaParsing/CalculateExtensions.cs b/src/EPPlus/FormulaParsing/CalculateExtensions.cs index 1af3e851d7..89f4353895 100644 --- a/src/EPPlus/FormulaParsing/CalculateExtensions.cs +++ b/src/EPPlus/FormulaParsing/CalculateExtensions.cs @@ -62,6 +62,10 @@ public static void Calculate(this ExcelWorkbook workbook, ActionCalculation options public static void Calculate(this ExcelWorkbook workbook, ExcelCalculationOption options) { + #if !NET35 + workbook.ThrowIfCalculationCancelled(); // Guard: prevent recalc on poisoned workbook + #endif + Init(workbook); var filterInfo = new FilterInfo(workbook); @@ -74,13 +78,25 @@ public static void Calculate(this ExcelWorkbook workbook, ExcelCalculationOption } //CalcChain(workbook, workbook.FormulaParser, dc, options); - var dc=RpnFormulaExecution.Execute(workbook, options); - dc._parsingContext.RangeCriteriaCache?.Clear(); - if (workbook.FormulaParser.Logger != null) +#if !NET35 + try { - var msg = string.Format("Calculation done...number of cells parsed: {0}", dc.processedCells.Count); - workbook.FormulaParser.Logger.Log(msg); +#endif + var dc =RpnFormulaExecution.Execute(workbook, options); + dc._parsingContext.RangeCriteriaCache?.Clear(); + if (workbook.FormulaParser.Logger != null) + { + var msg = string.Format("Calculation done...number of cells parsed: {0}", dc.processedCells.Count); + workbook.FormulaParser.Logger.Log(msg); + } +#if !NET35 } + catch (OperationCanceledException) + { + workbook.MarkCalculationCancelled(); + throw; + } +#endif } internal static RpnOptimizedDependencyChain CalculateWithDC(this ExcelWorkbook workbook, Action configHandler) { @@ -158,8 +174,21 @@ public static void Calculate(this ExcelWorksheet worksheet, Action @@ -195,15 +224,23 @@ public static void Calculate(this ExcelRangeBase range, ActionCalculation options public static void Calculate(this ExcelRangeBase range, ExcelCalculationOption options) { - Init(range._workbook); - //var parser = range._workbook.FormulaParser; - //var filterInfo = new FilterInfo(range._workbook); - //parser.InitNewCalc(filterInfo); - //var dc = DependencyChainFactory.Create(range, options); - //CalcChain(range._workbook, parser, dc, options); - var dc = RpnFormulaExecution.Execute(range, options); - // Clear RangeCriteriaCache after calculation completes - dc._parsingContext.RangeCriteriaCache?.Clear(); +#if !NET35 + range._workbook.ThrowIfCalculationCancelled(); + try + { +#endif + Init(range._workbook); + var dc = RpnFormulaExecution.Execute(range, options); + // Clear RangeCriteriaCache after calculation completes + dc._parsingContext.RangeCriteriaCache?.Clear(); +#if !NET35 + } + catch (OperationCanceledException) + { + range._workbook.MarkCalculationCancelled(); + throw; + } +#endif } /// diff --git a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs index a9b6b114fb..42e29e4c85 100644 --- a/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs +++ b/src/EPPlus/FormulaParsing/DependencyChain/RpnFormulaExecution.cs @@ -46,6 +46,9 @@ internal static RpnOptimizedDependencyChain Execute(ExcelWorkbook wb, ExcelCalcu var depChain = new RpnOptimizedDependencyChain(wb, options); foreach (var ws in wb.Worksheets) { +#if !NET35 + options.CancellationToken.ThrowIfCancellationRequested(); +#endif if (ws.IsChartSheet == false) { ExecuteChain(depChain, ws.Cells, options, true); @@ -118,9 +121,15 @@ private static void ExecuteChain(RpnOptimizedDependencyChain depChain, ExcelRang { var ws = range.Worksheet; RpnFormula f = null; +#if !NET35 + var ct = options.CancellationToken; // Cache locally — avoids property lookup in hot loop +#endif var fs = new CellStoreEnumerator(ws._formulas, range._fromRow, range._fromCol, range._toRow, range._toCol); while (fs.Next()) { +#if !NET35 + ct.ThrowIfCancellationRequested(); // P0 – per cell +#endif if (fs.Value == null || fs.Value.ToString().Trim() == "") continue; var id = ExcelCellBase.GetCellId(ws.IndexInList, fs.Row, fs.Column); if (depChain.processedCells.Contains(id) == false) @@ -132,6 +141,12 @@ private static void ExecuteChain(RpnOptimizedDependencyChain depChain, ExcelRang CalculateFormulaChain(depChain, f, options, writeToCell); } } +#if !NET35 + catch (OperationCanceledException) + { + throw; // Must propagate — do not swallow + } +#endif catch (CircularReferenceException) { throw; @@ -240,6 +255,12 @@ private static void ExecuteChain(RpnOptimizedDependencyChain depChain, ExcelName ExecuteName(depChain, name, options, writeToCell); } } +#if !NET35 + catch (OperationCanceledException) + { + throw; // Must propagate + } +#endif catch (CircularReferenceException) { throw; @@ -289,6 +310,12 @@ private static object ExecuteChain(RpnOptimizedDependencyChain depChain, ExcelWo f.SetFormula(formula, depChain); return CalculateFormulaChain(depChain, f, options, writeToCell).Result; } +#if !NET35 + catch (OperationCanceledException) + { + throw; // Must propagate + } +#endif catch (CircularReferenceException) { throw; @@ -309,6 +336,12 @@ private static object ExecuteChain(RpnOptimizedDependencyChain depChain, ExcelWo f._row = -1; return CalculateFormulaChain(depChain, f, options, writeToCell).Result; } +#if !NET35 + catch (OperationCanceledException) + { + throw; // Must propagate + } +#endif catch (CircularReferenceException) { throw; @@ -419,6 +452,9 @@ private static CompileResult CalculateFormulaChain(RpnOptimizedDependencyChain d ExecuteFormula: try { +#if !NET35 + options.CancellationToken.ThrowIfCancellationRequested(); // P0 – per dependency step +#endif SetCurrentCell(depChain, f); var ws = f._ws; if (f._tokenIndex < f._tokens.Count) @@ -568,6 +604,12 @@ private static CompileResult CalculateFormulaChain(RpnOptimizedDependencyChain d goto ExecuteFormula; } +#if !NET35 + catch (OperationCanceledException) + { + throw; // Must propagate + } +#endif catch (CircularReferenceException) { throw; diff --git a/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs b/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs index 25e14bebd3..33e547c3f8 100644 --- a/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs +++ b/src/EPPlus/FormulaParsing/ExcelCalculationOption.cs @@ -15,6 +15,12 @@ Date Author Change using System; using System.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; +#elif (!NET35) +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; #else using System.Configuration; using System.Collections.Generic; @@ -115,5 +121,22 @@ public bool EnableUnicodeAwareStringOperations { get; set; } = false; + +#if !NET35 + /// + /// A cancellation token that can be used to cancel a running calculation. + /// When cancelled, an will be thrown + /// and the workbook will be left in an inconsistent, partially calculated state. + /// The workbook must be discarded after cancellation — saving or recalculating + /// a cancelled workbook is not permitted. + /// + /// + /// + /// using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + /// workbook.Calculate(opt => opt.CancellationToken = cts.Token); + /// + /// + public CancellationToken CancellationToken { get; set; } = CancellationToken.None; +#endif } } diff --git a/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs b/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs new file mode 100644 index 0000000000..a19984bb80 --- /dev/null +++ b/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs @@ -0,0 +1,146 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OfficeOpenXml; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace EPPlusTest.FormulaParsing +{ + [TestClass] + public class CancelCalculationTests + { + const int WaitTimeMs = 150; + + [TestMethod] + public void CancelCalculation() + { + using var package = CreateHeavyChain(chainLength: 1500, sheetCount: 3); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(WaitTimeMs)); + + var sw = Stopwatch.StartNew(); + try + { + package.Workbook.Calculate(opt => + { + opt.CancellationToken = cts.Token; + }); + Assert.Fail("Expected OperationCanceledException was not thrown."); + } + catch (OperationCanceledException) + { + sw.Stop(); + Debug.WriteLine($"Calculation cancelled after {sw.Elapsed.TotalSeconds:F2} seconds."); + Assert.IsTrue(sw.Elapsed.TotalSeconds < 10, "Cancellation took too long."); + Assert.IsTrue(package.Workbook.IsCalculationInconsistent); + } + } + + [TestMethod] + public void Save_AfterCancelledCalculation_ThrowsInvalidOperationException() + { + using var package = CreateHeavyChain(chainLength: 1500, sheetCount: 3); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(WaitTimeMs)); + using var outputStream = new MemoryStream(); + + try + { + package.Workbook.Calculate(opt => + { + opt.CancellationToken = cts.Token; + }); + } + catch (OperationCanceledException) { /* expected */ } + + Assert.ThrowsExactly(() => + { + package.SaveAs(outputStream); + }); + } + + [TestMethod] + public void CancelCalculation_AlreadyCancelledToken_ThrowsImmediately() + { + using var package = CreateHeavyChain(chainLength: 1500, sheetCount: 3); + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Signal before calculate + + Assert.ThrowsExactly(() => + { + package.Workbook.Calculate(opt => opt.CancellationToken = cts.Token); + }); + Assert.IsTrue(package.Workbook.IsCalculationInconsistent); + } + + [TestMethod] + public void CancelCalculation_RecalculatePoisonedWorkbook_ThrowsInvalidOperationException() + { + using var package = CreateHeavyChain(chainLength: 1500, sheetCount: 3); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(WaitTimeMs)); + + try + { + package.Workbook.Calculate(opt => opt.CancellationToken = cts.Token); + } + catch (OperationCanceledException) { /* expected */ } + + Assert.ThrowsExactly(() => + { + package.Workbook.Calculate(); // Should throw — workbook is poisoned + }); + } + + [TestMethod] + public void CancelCalculation_FromAnotherThread() + { + using var package = CreateHeavyChain(chainLength: 1500, sheetCount: 3); + using var cts = new CancellationTokenSource(); + Exception caughtException = null; + + var calcThread = new Thread(() => + { + try + { + package.Workbook.Calculate(opt => opt.CancellationToken = cts.Token); + } + catch (OperationCanceledException ex) + { + caughtException = ex; + } + }); + + calcThread.Start(); + Thread.Sleep(WaitTimeMs); // Let calculation run for a while + cts.Cancel(); // Cancel from this (main) thread + calcThread.Join(TimeSpan.FromSeconds(10)); // Wait for calc thread to finish + + Assert.IsFalse(calcThread.IsAlive, "Calculation thread did not terminate."); + Assert.IsInstanceOfType(caughtException); + Assert.IsTrue(package.Workbook.IsCalculationInconsistent); + } + + + + public static ExcelPackage CreateHeavyChain(int chainLength = 1_000, int sheetCount = 3) + { + var package = new ExcelPackage(); + + for (int s = 1; s <= sheetCount; s++) + { + var ws = package.Workbook.Worksheets.Add($"Sheet{s}"); + ws.Cells[1, 1].Value = 1; + + for (int row = 2; row <= chainLength; row++) + { + // SUMPRODUCT over a growing range — O(N²) total work + ws.Cells[row, 1].Formula = $"SUMPRODUCT(A$1:A{row - 1})+1"; + } + } + + return package; + } + } +} From 8cfce31f736f0dfd51e698536a4244f7977ae3be Mon Sep 17 00:00:00 2001 From: swmal <897655+swmal@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:17:55 +0100 Subject: [PATCH 2/2] #2260 - Addes some more unittests --- .../FormulaParsing/CancelCalculationTests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs b/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs index a19984bb80..dbc690f252 100644 --- a/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs +++ b/src/EPPlusTest/FormulaParsing/CancelCalculationTests.cs @@ -122,7 +122,61 @@ public void CancelCalculation_FromAnotherThread() Assert.IsTrue(package.Workbook.IsCalculationInconsistent); } + [TestMethod] + public void Calculate_WithToken_CompletesNormally_IsConsistent() + { + using var package = new ExcelPackage(); + var ws = package.Workbook.Worksheets.Add("Sheet1"); + ws.Cells["A1"].Formula = "1+1"; + + using var cts = new CancellationTokenSource(); + package.Workbook.Calculate(opt => opt.CancellationToken = cts.Token); + + Assert.AreEqual(2d, ws.Cells["A1"].Value); + Assert.IsFalse(package.Workbook.IsCalculationInconsistent); + } + + [TestMethod] + public void CancelCalculation_WithHeavyNamedRanges() + { + using var package = new ExcelPackage(); + var wb = package.Workbook; + var ws = wb.Worksheets.Add("Sheet1"); + + // Fill source data + for (int row = 1; row <= 1000; row++) + { + ws.Cells[row, 1].Value = row; + } + // Create named ranges with heavy formulas referencing each other + for (int i = 0; i < 800; i++) + { + wb.Names.Add($"HeavyName{i}", ws.Cells["A1:A500"]); + ws.Cells[1, i + 2].Formula = $"SUMPRODUCT(HeavyName{i},HeavyName{i})"; + } + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(WaitTimeMs)); + + Assert.ThrowsExactly(() => + { + wb.Calculate(opt => opt.CancellationToken = cts.Token); + }); + Assert.IsTrue(wb.IsCalculationInconsistent); + } + + [TestMethod] + public void CancelCalculation_WorksheetLevel() + { + using var package = CreateHeavyChain(chainLength: 1500, sheetCount: 1); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(WaitTimeMs)); + + Assert.ThrowsExactly(() => + { + package.Workbook.Worksheets[0].Calculate(opt => opt.CancellationToken = cts.Token); + }); + Assert.IsTrue(package.Workbook.IsCalculationInconsistent); + } public static ExcelPackage CreateHeavyChain(int chainLength = 1_000, int sheetCount = 3) {