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

[Pester 4.0.3] Call depth overflow on $xmlObject | Should be $xmlObject #785

Closed
alx9r opened this issue Jun 4, 2017 · 14 comments
Closed
Assignees
Milestone

Comments

@alx9r
Copy link
Member

alx9r commented Jun 4, 2017

Repro

Describe 'overflow' {
    It '[xml] object is itself.' {
        $doc = [xml]''
        $doc | Should be $doc
    }
}

Expected Result

I expected this test to pass. It passes using Pester 3.4.3.

Actual Result

Describing overflow
  [-] [xml] object is itself. 45.77s
    The script failed due to call depth overflow.
    at <ScriptBlock>, : line 4
    4:         $doc | Should be $doc

Environment

PS C:\> $PSVersionTable

Name                           Value                      
----                           -----                      
PSVersion                      5.1.14409.1005             
PSEdition                      Desktop                    
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}    
BuildVersion                   10.0.14409.1005            
CLRVersion                     4.0.30319.42000            
WSManStackVersion              3.0                        
PSRemotingProtocolVersion      2.3                        
SerializationVersion           1.1.0.1                    

PS C:\> Get-Module Pester

ModuleType Version    Name                                
---------- -------    ----                                
Script     4.0.3      Pester                              
@alx9r alx9r changed the title Call depth overflow on $xmlObject | Should be $xmlObject [Pester 4.0.3] [Pester 4.0.3] Call depth overflow on $xmlObject | Should be $xmlObject Jun 4, 2017
@nohwnd nohwnd added this to the V4 milestone Jul 25, 2017
@alx9r
Copy link
Member Author

alx9r commented Jul 29, 2017

The problem seems to manifest in this recursive call in ArraysAreEqual. Here is a repro directly calling ArraysAreEqual:

InModuleScope Pester {
    Describe ArraysAreEqual {
        It '[xml] object is itself.' {
            $doc = [xml]''
            ArraysAreEqual -First $doc -Second $doc
        }
    }
}

@alx9r
Copy link
Member Author

alx9r commented Jul 29, 2017

I think the original repro does not fail with Pester 3.4.3 because 3.4.3 predates array comparison. As of 2541ef9 Pester seems to treat most IEnumerables the same as arrays for comparison.

I think that might not be a great strategy. It looks to me like ArraysAreEqual uses the subscript operator to iterate into and compare the "Expected" and "Actual" arrays passed to Should. I'm not aware of there being much of a relationship between an object being IEnumerable and what, exactly, the subscript operator returns.

alx9r added a commit to alx9r/ToolFoundations that referenced this issue Aug 2, 2017
alx9r added a commit to alx9r/ToolFoundations that referenced this issue Aug 3, 2017
@alx9r
Copy link
Member Author

alx9r commented Aug 3, 2017

I think this could be adequately mitigated by implementing a recursion limit that, when reached, throws an understandable error. That would cause a fast failure instead of pinning one CPU until a call depth overflow occurs. The call depth overflow can take minutes to occur, so a fast failure would be a significant improvement.

@nohwnd
Copy link
Member

nohwnd commented Aug 12, 2017

@alx9r Been looking at this to see if there is a simple way of seeing what PowerShell wraps in @() but if I get the login of the compiler correctly, it does a lot of work to identify which implementations of IEnumerable should be considered collections.

For the Should assertion we need to identify a simple set of rules that covers the majority of cases, and can be immediately understood. In the end comparing the arrays as part of Be is a convenience thing. I would suggest changing the collection check to inheriting from Array, which covers 90% of the collections a powersheller encounters, or moving the behavior to a new assertion alltogether.

Thoughts?

@nohwnd
Copy link
Member

nohwnd commented Aug 12, 2017

One thing that I also tried was simply checking the result of @() to see if the value remained the same, but Object.ReferenceEquals does not work in this case, because the reference changes and the result is always $false.

nohwnd added a commit that referenced this issue Aug 12, 2017
…ble (#818)

* Fix call depth overflow on non-collection types implementing IEnumerable

Fix #785
@nohwnd nohwnd self-assigned this Aug 12, 2017
alx9r added a commit to alx9r/Pester that referenced this issue Aug 16, 2017
self-limit recursion depth to cause fast(er) failure in case cyclic object graphs are encountered
@alx9r
Copy link
Member Author

alx9r commented Aug 16, 2017

@nohwnd I don't think the cause of the infinite recursion involves whether a collection is considered enumerable. Given the current algorithm's dependence on indexing into the collection, I think the most appropriate test would be for the IList interface. That's the framework interface customarily used to access items by index.

Here's a test of a bunch of collections I've encountered using PowerShell:

Describe 'IList' {
    $h= @{}
    foreach ( $values in  @(
        @($false,$true,(New-Object string 'TenTwentyThirty')),
        @($true,$true,([System.Collections.ArrayList]@(10,20,30))),
        @($false,$false,(New-Object System.Collections.BitArray 16)),
        @($false,$false,([System.Collections.Hashtable]@{ten=10;twenty=20;thirty=30})),
        @($false,$true,([System.Collections.Queue]@(10,20,30))),
        @($false,$false,([System.Collections.SortedList]@{ten=10;twenty=20;thirty=30})),
        @($false,$true,([System.Collections.Stack]@(10,20,30))),
        @($false,$false,(& {
            $d = New-Object "System.Collections.Generic.Dictionary``2[System.String,int32]"
            ('ten',10),('twenty',20),('thirty',30) | % {$d.Add($_[0],$_[1])}
            $d
        })),
        @($true,$true,(& {
            $l = New-Object "System.Collections.Generic.Queue``1[int32]"
            10,20,30 | % {$l.Enqueue($_)}
            $l
        })),
        @($true,$true,(& {
            $l = New-Object "System.Collections.Generic.List``1[int32]"
            10,20,30 | % {$l.Add($_)}
            $l
        })),
        @($false,$false,([xml]''))
    ))
    {
        $isIList,$subscriptWorks,$object = $values
        $type = $object.GetType()
        Context "$($type.Name), $($type.BaseType)" {
            It "$isIList, is IList" {
                [bool]$type.GetInterface('IList') | Should be $isIList            
            }
            It "$subscriptWorks, subscript operator returns something" {
                [bool]$object[0] | Should be $subscriptWorks
            }
        }
    }
}

I think .GetInterface('IList') tells us what we need to know. I've opened #824 with the fixes I had in mind.

@nohwnd
Copy link
Member

nohwnd commented Aug 16, 2017

@alx9r Revisited the problem and I think my implementation is correct, pls review :)

It does matter, because when there is mismatch between what we consider a collection and what powershell considers a collection, then on the recursive call we wrap the object in an array, then we unwrap it, incorrectly mark it as an array and call ArraysAreEqual, it wraps it in array, then unwraps it again, calls ArraysAreEqual and so on ad-infinitum.

This implementation highlights the problem. The [xml] type is IEnumerable, and so the implementation chokes.

function IsCollection ($o) {
    $o -isnot [string] -and $o -is [Collections.IEnumerable] 
}

function ArraysAreEqual ([object[]] $o, $i=0) {    
    "Call $i"
    "Object is: " + $o.GetType().FullName
    $first = $o[0]
    "First is: " + $first.GetType().FullName

    if ($i -gt 2) { throw "call is recursive" }
    if (IsCollection $first) 
    {
        ArraysAreEqual  $first (++$i)
    }
}

"`nUsing Int:"
ArraysAreEqual 1
"`n`nUsing String:"
# this would have failed but we explicitly exclude string
# in IsCollection function
ArraysAreEqual "abc" 
"`n`nUsing Array:"
ArraysAreEqual 1,2,3
"`n`nUsing List:"
ArraysAreEqual ([System.Collections.Generic.List[int]]@(1,2,3))
"`n`nUsing XmlDocument:"
ArraysAreEqual ([xml]'')

You can trigger overflow on string as well if you remove it from the IsCollection function, in fact you can make it choke on int as well, or any non-collection type, if you just return $true from the IsCollection function. Collections types will try to unwrap till they hit a non-collection type, and then it chokes again.

Now if you try and take different collections and enumerable types and cast them to [object[]] as the paramter does, you will get [object[]], but the conversion is clever the same way @() is clever . It correctly wraps values that are enumerable but not considered collections in an array, and does not wrap values that already are collections. So inside of the IsCollection function it really is sufficient to check for array, because that is what we will ever get.

So the fixed code should be what is currently in the master:

function IsCollection ($o) {
    $o -is [array]
}

function ArraysAreEqual ([object[]] $o, $i=0) {    
    "Call $i"
    "Object is: " + $o.GetType().FullName
    $first = $o[0]
    "First is: " + $first.GetType().FullName

    if ($i -gt 2) { throw "call is recursive" }
    if (IsCollection $first) 
    {
        ArraysAreEqual  $first (++$i)
    }
}

$innerList = [System.Collections.Generic.List[string]]("hello", "its", "me")
$list = [System.Collections.Generic.List[System.Collections.Generic.List[string]]]$innerList
ArraysAreEqual $list

Unless we want to consider types that Powershell does not consider collections as collections (say Dictionary).

@alx9r
Copy link
Member Author

alx9r commented Aug 16, 2017

@nohwnd I think your analysis is correct. I misunderstood how that algorithm was expected to work. This leaves me with two remaining concerns: Naming and cyclic object graphs.

Shouldn't IsCollection really be named IsArray? Termination of ArraysAreEqual seems to depend very much on testing for precisely whatever conversion to [object[]] creates. It seems like that should be reflected in the function name.

The specter of infinite recursion seems to remain for cyclic object graphs. The head of master currently fails by call depth overflow on this:

$a1 = @(0,1)
$a2 = @($a1,2)
$a1[0] = $a2
$a1 | Should be $a2

Because of this I think there ought to be a self-imposed recursion limit.

I've updated PR #824 with all of this in mind. Let me know what you think.

@nohwnd
Copy link
Member

nohwnd commented Aug 16, 2017 via email

@alx9r
Copy link
Member Author

alx9r commented Aug 16, 2017

Those all look like good recommendations. Thank you.

@it-praktyk
Copy link
Contributor

@nohwnd, @alx9r I've followed your discussion and I'm little confused if the 631ef02 addresses

I would just make the error message more descriptive, but that can be fixed by someone else.

or should be added/changed something more.

@alx9r
Copy link
Member Author

alx9r commented Aug 17, 2017

@it-praktyk That suggestion prompted this change, but I'm not sure exactly what @nohwnd had in mind.

@nohwnd nohwnd reopened this Aug 17, 2017
@nohwnd nohwnd closed this as completed in ad7705d Aug 17, 2017
@nohwnd
Copy link
Member

nohwnd commented Aug 17, 2017

@it-praktyk The error seems fine now, the small suggestion of what probably have happened is what I was looking for 👍
@alx9r You forgot to propagate the recursion limit to the subsequent calls so it would always stay 100 after the first call. I fixed that, then some casing and spacing and merged. Thanks :)

@alx9r
Copy link
Member Author

alx9r commented Aug 17, 2017

@nohwd Oh right. Nice spot. Thanks for the merge. :)

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

3 participants