Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Assert.Equivalent stack overflows when comparing FileInfo objects #2767

Closed
Blokyk opened this issue Aug 26, 2023 · 5 comments
Closed

Assert.Equivalent stack overflows when comparing FileInfo objects #2767

Blokyk opened this issue Aug 26, 2023 · 5 comments

Comments

@Blokyk
Copy link

Blokyk commented Aug 26, 2023

Version: 2.5.0

The following test crashes with a stack overflow, provided that the file exist:

[Fact]
public void SameFileInfosAreEquivalent() {
    var path = "testhost.dll"; // or any other file (not folder) that exists
    Assert.Equivalent(new FileInfo(path), new FileInfo(path));
}

Log dump:

The active test run was aborted. Reason: Test host process crashed : Stack overflow.
Repeat 21051 times:
--------------------------------
   at Xunit.Internal.AssertHelper.VerifyEquivalenceReference(System.Object, System.Object, Boolean, System.String, System.Collections.Generic.HashSet`1<System.Object>, System.Collections.Generic.HashSet`1<System.Object>)
   at Xunit.Internal.AssertHelper.VerifyEquivalence(System.Object, System.Object, Boolean, System.String, System.Collections.Generic.HashSet`1<System.Object>, System.Collections.Generic.HashSet`1<System.Object>)
--------------------------------
   at Xunit.Internal.AssertHelper.VerifyEquivalence(System.Object, System.Object, Boolean)
   at Xunit.Assert.Equivalent(System.Object, System.Object, Boolean)
   at Recline.Tests.Options.Tests.SameFileInfoAreEquivalent()
   at [test host stuff]
@Blokyk
Copy link
Author

Blokyk commented Aug 26, 2023

After a bit a debugging, it seems like this is because FileInfo.Directory has a the following property:

public DirectoryInfo Root => new DirectoryInfo(Path.GetPathRoot(FullPath)!);

And since DirectoryInfo has reference equality, .Root and .Root.Root are different, the actualRefs/expectedRefs tables can't see that it's just a circular reference 🙃

Is this even fixable? Or would this require changing the property's definition in dotnet/runtime ?

@bradwilson
Copy link
Member

So a quick though on using Assert.Equal vs. Assert.Equivalent here.

I added Assert.Equivalent to test that two objects were structurally equal without being forced to be the same type. The goal was to make certain types of testing easier: for example, deserializing an object (like from JSON or XML) and being able to easily test it (partially or fully) by describing that object with something like an anonymous type.

Here's an example asserting a complex value using just anonymous types to describe the expected structure:

[Fact]
public void Success()
{
var expected = new { Shallow = new { Value1 = 42, Value2 = "Hello, world!" }, Value3 = 21.12m };
var actual = new DeepClass { Value3 = 21.12m, Shallow = new ShallowClass { Value1 = 42, Value2 = "Hello, world!" } };
Assert.Equivalent(expected, actual);
}

Your example looks more like you're after equality than equivalence. (More about the problems with that in a second, because I'm guessing you already tried Assert.Equal and had a failure.)

One way to continue to use Assert.Equivalent is to describe the "shape" of the FileInfo you expect to get back (with only the properties you care about testing), just like my example above:

[Fact]
public void Xunit2767()
{
    var expected = new { Name = "README.md", DirectoryName = @"C:\Dev\xunit\xunit.v2", Exists = true };
    var actual = new FileInfo(@"C:\Dev\xunit\xunit.v2\README.md");

    Assert.Equivalent(expected, actual);
}

The difference here is that we use the expected value to decide what to look at in the actual value. When the expected value is a FileInfo, we're going to explore all public properties, and that causes the stack overflow; when the expected value is the anonymous object, then the only properties we inspect will be the ones you put into the expectation (in this case, we only care about the values of .Name, .DirectoryName, and .Exists, and we ignore any additional properties on the actual object).

Is this even fixable?

The answer is that I think there are two separate issues here, and both of them are fixable:

  • To be able to just pass two FileInfo objects into Assert.Equivalent and have it pass, the answer is that we have to update Assert.Equivalent to special-case FileInfo. We have a special case already in the code for DateTime and DateTimeOffset because of a circular reference problem: https://github.com/xunit/assert.xunit/blob/d0a234a523e8a898dd7f0d36d97876a68be8b6a9/Sdk/AssertHelper.cs#L237-L240

    We could add a similar helper to compare FileInfo and/or DirectoryInfo to prevent the stack overflow problem by just ignoring Directory and only comparing the local properties, much like the example above.

  • We should probably also add a "depth cap" when running Assert.Equivalent which would flag the error before it gets to the point of being a full blown StackOverflowExecption. It would generally raise itself with a message that says something like "we hit the maximum depth while comparing these two objects, which may indicate that you have a circular reference problem".

would this require changing the property's definition in dotnet/runtime ?

Neither of my proposals above require any changes to .NET itself. Any attempts to fix it there would likely incur extra expense (in terms of computation and/or memory usage) that the team would probably be reluctant to accept, especially since it's really only solving a testing problem, not a runtime problem.

That said, I do think it would be worth opening a PR to add IEquatable<> implementations to both FileInfo and DirectoryInfo. That would allow them to be used directly by Assert.Equal (which is what I think should be used to compare two concrete objects of the same type), and allow them to pass without forcing the developer to write their own custom comparison function (or equality comparer object).

@bradwilson
Copy link
Member

BTW, here's what the Assert.Equal with a custom comparison function might look like that does the same thing as Assert.Equivalent with the anonymous type:

[Fact]
public void Xunit2767()
{
    var expected = new FileInfo(@"C:\Dev\xunit\xunit.v2\README.md");
    var actual = new FileInfo(@"C:\Dev\xunit\xunit.v2\README.md");

    Assert.Equal(
        expected, actual,
        (e, a) => e.Name == a.Name && e.DirectoryName == a.DirectoryName && e.Exists == a.Exists
    );
}

bradwilson added a commit to xunit/assert.xunit that referenced this issue Sep 1, 2023
bradwilson added a commit that referenced this issue Sep 1, 2023
bradwilson added a commit that referenced this issue Sep 1, 2023
bradwilson added a commit to xunit/assert.xunit that referenced this issue Sep 1, 2023
bradwilson added a commit that referenced this issue Sep 1, 2023
bradwilson added a commit that referenced this issue Sep 1, 2023
@bradwilson
Copy link
Member

Shipping in v2: 2.5.1-pre.25
Shipping in v3: 0.1.1-pre.274

@Blokyk
Copy link
Author

Blokyk commented Sep 3, 2023

Sorry for the late reply -- but thank you a lot for the fix!

Just to answer this:

One way to continue to use Assert.Equivalent is to describe the "shape" of the FileInfo you expect to get back

In this case I was a few levels deep into an anonymous object and re-using an existing static readonly value that had a FileInfo property (mostly cause I'm lazy lol)

bradwilson added a commit to xunit/assert.xunit that referenced this issue Sep 3, 2023
agocke added a commit to agocke/arcade that referenced this issue Oct 11, 2023
…a..46dfaa92

46dfaa92 xunit/xunit#2773: Add Assert.RaisesAny and Assert.RaisesAnyAsync non-generic for EventArgs
c0485bdd Add additional NETSTANDARD excludeions for System.AppDomain usage
b6cb339c xunit/xunit#2767: Verify types match when comparing FileSystemInfo values
03b07513 Use AppDomain.CurrentDomain.GetAssemblies() to find System.IO.FileSystemInfo type
482be8e0 xunit/xunit#2767: Special case FileSystemInfo objects by just comparing the FullName in Assert.Equivalent
78d70dec xunit/xunit#2767: Prevent stack overflow in Assert.Equivalent with a max traversal depth of 50
d0a234a5 Delay calling Dispose() on CollectionTracker's inner enumerator, for xunit/xunit#2762
96236a4c Add disable for CS8607 in AssertEqualityComparerAdapter because of nullability inconsistency
6193f9ee Move to explicit DateTime(Offset) handling in Assert.Equivalent (related: xunit/xunit#2758)
20e76223 xunit/xunit#2743: Assert.Equal with HashSet and custom comparer ignores comparer
247c1016 Mark BitArray as safe to multi-enumerate
8926c0fc Wrap AssertEqualityComparer collection logic in !XUNIT_FRAMEWORK for v2 xunit.execution.*
90d59772 xunit/xunit#2755: Assert.Equal regression for dictionaries with collection values
17c7b611 Add nullable annotation to Assert.NotNull<T>(T?) (dotnet#64)
1886f126 xunit/xunit#2741: Ensure all exception factory methods are public

git-subtree-dir: src/Microsoft.DotNet.XUnitAssert/src
git-subtree-split: 46dfaa9248b7aa4c8c88e5cf6d4a6c84671a93f5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants