Pass PowerShell class to worker thread which invokes nested methods

Viewed 26

I've seen this behaviour with both ThreadJobs and using Runspaces. When passing an instance of a PowerShell class, either through $using: or through -ArgumentList, odd behaviour ensues.

The exact odd behaviour is that the Job or Task never completes. It is always in state "Running" forever and ever. This code should allow anyone to replicate the problem at will

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

class test_class : System.IDisposable
{
    test_class([int] $Id)
    {
        $this._id = $Id
    }

    [int] $_id

    [void] Dingaling()
    {
        "{0}:{1} is disposed? {2}" -f $this.GetType(), $this._id, $this._disposed | Out-Host
    }

    [int] GetId()
    {
        return $this._id
    }

    [void] NullFunc()
    {
        # does nothing at all!
    }

    [void] CallThisNullFunc()
    {
        "calling this.NullFunc" | Out-Host
        "our type is $($this.GetType())" | Out-Host
        $this | Out-String | Out-Host
        #$this.NullFunc | Out-String | Out-Host # fatal
        $this.NullFunc() # fatal
        "returned from this.NullFunc" | Out-Host
    }

    [void] CallThisDispose()
    {
        "yo ho ho, callin' this.Dispo'" | Out-Host
        $this.Dispose()
    }
    
    $_disposed = $false

    [void] Dispose()
    {
        $this.Dispose($true)
        #[System.GC]::SuppressFinalize($this)
    }

    [void] Dispose([bool] $Disposing)
    {
        "yarrr we be disposin' {0}!" -f $this._id | Out-Host
        $this._disposed = $true
    }
    
    [void] Finalize()
    {
        "yarrr we be finalizin' {0}!" -f $this._id | Out-Host
        $this.Dispose($false)
    }
} # end class

function test_func2
{
    $test2 = [test_class]::new(2)
    $test2.Dingaling()
    #$test2 = $null # should be collected by gc
}

test_func2
"call gc after test_func2"
[System.GC]::Collect()

$otherPs = [powershell]::Create()
$otherPs.RunspacePool = [runspacefactory]::CreateRunspacePool(1,2)
$otherPs.RunspacePool.Open()

function test_func
{
    # use a function so test goes out of scope
    $test = [test_class]::new(1)

    $otherPs.AddScript({param($test)
        #$test.Dispose() # no problems if called here
        $test.CallThisNullFunc() # fine here
        $test.Dingaling()
        
        Start-Sleep -Seconds 10
        
        $test.Dingaling()
        $test.Dingaling()
        #$test.Dispose() # fatal if called here
        $test.Dingaling()
        "our id is $($test.GetId())"
        $test.NullFunc() # this is fine
        $test.CallThisNullFunc() # fatal if called here, script will never end
        #$test.CallThisDispose() # also fatal here
        #$test.Dispose() # or here
    })
    $otherPs.AddArgument($test)
}

"call test_func"
test_func

"begin invoke"
$timer = [System.Diagnostics.Stopwatch]::new()
$timer.Start()
$runState = $otherPs.BeginInvoke()

#Start-Sleep -Seconds 15 # doesn't help

while (-not $runState.IsCompleted) {
    "not done yet: {0}" -f ($runState | Out-String)
    Start-Sleep -MilliSeconds 3000
    [System.GC]::Collect()

    if (20 -lt $timer.Elapsed.TotalSeconds) {
        "terminate test after {0}s" -f $timer.Elapsed.TotalSeconds
        break
    }
}

$otherPs.InvocationStateInfo # "running"... forever
$otherPs.EndInvoke($runState) # fails to return
"runspace faulted? {0}" -f $otherPs.HadErrors
$otherPs.Streams.Error | Out-Host

$otherPs.RunspacePool.Close()
$otherPs.Dispose()
$otherPs = $null

1..10 | ForEach-Object {
    Start-Sleep -MilliSeconds 1000
    "do gc collection then quit"
    [System.GC]::Collect()
}

Why am I doing this anyhow? I have a method that creates some IDisposable objects, then calls a .NET function that does some async copying. After the copying I want to dispose of the IDisposables.

Would have been nice to do something like $httpResponse.Content.Stream.CopyAsync($outStream).ContinueWith({$outstream.Dispose(); $httpResponse.Dispose()}). However this is not possible like Task.Run({$PsVersionTable}) is not possible. There is no PS default runspace on the task thread.

So I have to save all the IDisposables as class fields, which is very ugly. And then there's no guarantee they get disposed, either. If the user of the class doesn't call $myClass.Cleanup() then the class fields storing the IDisposables probably even prevent disposal by the GC.

Now, passing $httpResponse to the ThreadJob or new Runspace has the aforementioned problem of (whatever is causing the above behaviour), and I can't create the $httpResponse in the Job script either. The http response object is created by some other part of the code.

I love PS but threading in PS seems to need a lot of patience and have a few limitations.

0 Answers
Related