Open-XML-SDK icon indicating copy to clipboard operation
Open-XML-SDK copied to clipboard

SpredsheetDocument.Save() InvalidOperationException (Collection was modified)

Open SirParser opened this issue 1 year ago • 11 comments

When executing SpreadsheetDocument.Save() (i.e. base class OpenXmlPackage.Save()) I'm getting the following error: InvalidOperationException Collection was modified; enumeration operation may not execute. StackTrace: [DBG]: PS ...>> $_.Exception.InnerException.StackTrace at System.ThrowHelper.ThrowInvalidOperationException(ExceptionResource resource) at System.Collections.Generic.Dictionary`2.Enumerator.MoveNext() at DocumentFormat.OpenXml.Features.PackageFeatureBase.UpdateCachedItems() at DocumentFormat.OpenXml.Packaging.Builder.SavePackageExtensions.SaveablePackage.Save() at DocumentFormat.OpenXml.Packaging.OpenXmlPackage.Save()

This happens only when I'm using the library in a PowerShell script. While debugging in JetBrains Rider (on the same machine) it works OK. It looks like something about the 'Cached Items' is handled differently when library is used after installing in 'C:\Program Files\PackageManagement\NuGet\Packages', comparing to debugging it in IDE. Has anyone encountered this issue?

Environment: Windows Server 2016 PowerShell 5.1 IDE: JetBrains Rider 2023.3.3 DocumentFormat.OpenXml.3.0.0

SirParser avatar Feb 03 '24 12:02 SirParser

I stepped through PackageFeatureBase.UpdateCachedItems() in DocumentFormat.OpenXml.Features internal abstract class PackageFeatureBase ... protected void UpdateCachedItems() { if (this._parts == null) return; foreach (KeyValuePair<Uri, PackageFeatureBase.PackagePart> part in this._parts) { if (this.Package.PartExists(part.Key)) part.Value.Part = this.Package.GetPart(part.Key); else this._parts.Remove(part.Key); } } I'm not an expert, but I thought you're not supposed to remove keys from a Dictionary while looping over it.

Anyway, my code does reach the 'Remove' part and deletes removed parts. I'm not sure if this should be happening. I might be doing something wrong earlier, maybe those obsolete parts need to be somehow handled explicitly, not sure how though.

SirParser avatar Feb 03 '24 16:02 SirParser

@twsouthwick can you take a look?

AlfredHellstern avatar Feb 06 '24 19:02 AlfredHellstern

@SirParser can you provide a repro for me?

twsouthwick avatar Feb 06 '24 19:02 twsouthwick

Thanks @AlfredHellstern and @twsouthwick attached steps to reproduce the error Repro.zip

SirParser avatar Feb 08 '24 07:02 SirParser

Oh - you're loading it manually through powershell and on an older version of powershell. You should be resolving the .net framework assemblies if you want to do as you're on Powershell 5.1

However, resolving them manually is difficult given powershell vs powershell core. We could potentially package them up as a powershell module so that things are resolved correctly. I haven't done that for a while, but I'd be open for taking a PR to enable an official powershell module that would set things up correctly.

twsouthwick avatar Feb 13 '24 16:02 twsouthwick

For more technical details, on .NET 6, enumerating over a dictionary that has had items removed is now supported. So, we special case our compilation to have a different pathway for .NET 6+ vs others: https://github.com/dotnet/Open-XML-SDK/blob/35b91d3c18fc06379b74f0980e3498f2b04c1fc8/src/DocumentFormat.OpenXml.Framework/Features/PackageFeatureBase.cs#L46-L82

twsouthwick avatar Feb 13 '24 16:02 twsouthwick

Thanks @twsouthwick.

Please excuse my ignorance, but my understanding so far was that netstandard2.0 dll's should work fine regardless of the target .Net environment. I'm loading the dll's manually in PowerShell because otherwise it doesn't know where to take them from.

Can you direct me to some link, or share more info that explains this in more detail:

You should be resolving the .net framework assemblies if you want to do as you're on Powershell 5.1

From what you wrote I understand that my PS script uses wrong version of PackageFeatureBase. It uses version with code designed for NET6_0_OR_GREATER (with direct key removal instead of via List<Uri>? unused). I don't understand why this is so considering I'm explicitly loading the netstandard2.0 dll version.

How can I force the PS script to use the correct version of dll? Is it something along the lines of: https://stackoverflow.com/questions/23268475/resolving-assembly-dependency-references-with-powershell

Or should I add .Net Framework 4.6 to the target frameworks of my library and use it instead of netstandard2.0? But then I thought netstandard2.0 is smart enough to work with whatever version of .Net ...

SirParser avatar Feb 17 '24 12:02 SirParser

I'll be out of town the next few days but let me try to answer these.

. NET standard in general works on all platforms but we have a specific framework build for actually running it on framework. This is due to a number of weirdness with the Windowsbase.dll implementation of the packaging Apis. So, things probably won't work well to use the net standard build on framework. I've never tested it and we expect people to use nuget to resolve the best platform assemblies.

I would try resolving the framework version of the assemblies and see if that works.

I'm not opposed to an official powers he'll module to simplify this resolution so if you want to submit a PR, happy to consider it.

The specific issue you're hitting is a weird one to hit even with the standard libs - I wouldn't expect it. I haven't had time to repro, but will try end of next week when I'm back

twsouthwick avatar Feb 17 '24 18:02 twsouthwick

Hi Taylor,

I haven't had time to look into this in the last few months, but some requirement popped up recently and I ran into this issue again. This time I wanted to use an Excel template with a table and that triggered again the UpdateCachedItems() method and the "Collection was modified..." error. I started pulling my hair out again trying to get the right versions and frameworks in place but to little avail. At least I kind of confirmed that the intended pre-NET.6 version of that method is being executed. Then I looked at the pre-NET.6 compiled code (and your code snippet above) and I realized that it doesn't actually address the dictionary item removal issue, because despite using an additional temp List ('unused') of keys (Uris), it still removes the OpenXmlPart within the original loop. For this to work, the removal part using the 'unused' List has to happen outside of the original loop over '_parts'. Can you check my quick mockup in attachment and let me know if that makes sense? Test.txt (it has to run in a .NET Framework 4.5+ Console App)

Regards, PM

SirParser avatar May 13 '24 11:05 SirParser

Here's the proposed change:

	protected void UpdateCachedItems() 
	{ 
		if (_parts is null) 
		{ 
			return; 
		} 
  
 #if !NET6_0_OR_GREATER 
        List<Uri>? unused = null; 
 #endif 
  
		foreach (var part in _parts) 
		{ 
			if (Package.PartExists(part.Key)) 
			{ 
				part.Value.Part = Package.GetPart(part.Key); 
			} 
			else 
			{ 
 #if NET6_0_OR_GREATER 
				_parts.Remove(part.Key); 
 #else 
				(unused ??= new()).Add(part.Key); 
 #endif 
			} 
// CHANGE START
		} 
 #if !NET6_0_OR_GREATER 
		if (unused is not null) 
		{ 
			foreach (var uri in unused) 
			{ 
				_parts.Remove(uri); 
			} 
		} 
 #endif 
// CHANGE END
	} 

SirParser avatar May 15 '24 09:05 SirParser

@twsouthwick can you have a look?

SirParser avatar May 25 '24 11:05 SirParser