Skip to content

Instantly share code, notes, and snippets.

@alx9r
Last active December 14, 2024 01:00
Show Gist options
  • Save alx9r/601734436bc3128aa900c085a399318a to your computer and use it in GitHub Desktop.
Save alx9r/601734436bc3128aa900c085a399318a to your computer and use it in GitHub Desktop.
Test setup for testing PowerShell object lifetime and cleanup when the runspace in which the executing script block is stopped.
# define a type with the following features:
# - can wait for a signal to complete contruction
# - records constructed and disposed flags such that it is beyond object lifetime
Add-Type @'
public class WaitingDisposableMock : System.IDisposable {
// global statics for tracking object lifetime
public static bool Disposed = false;
public static bool Constructed = false;
public WaitingDisposableMock(System.Threading.ManualResetEventSlim waitSignal) {
Disposed = false;
Constructed = true;
// Wait to complete construction if the signal exists.
if (null != waitSignal) {
waitSignal.Wait();
}
}
public void Dispose() {
Disposed = true;
}
}
'@
function Invoke-TestRun {
<#
.SYNOPSIS
Stop a partly-executed scriptblock and output information about progress, object lifetime, and cleanup.
#>
param(
[Parameter(Mandatory,ValueFromPipeline)]
[ValidateSet('begin{} before ctor','begin{} inside ctor','begin{} after ctor','clean{try{}}','clean{finally{}}')]
[string]
# The point in the scriptblock at which to way for .StopProcessing().
$WaitPoint
)
process {
# initialize the mock's globals
[WaitingDisposableMock]::Disposed = $false
[WaitingDisposableMock]::Constructed = $false
# set up the wait signal
$waitSignals = [System.Collections.Concurrent.ConcurrentDictionary[string,System.Threading.ManualResetEventSlim]]::new()
$waitSignals[$WaitPoint] = [System.Threading.ManualResetEventSlim]::new()
# create the log
$log = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()
$job =
Start-ThreadJob `
-argu $waitSignals,$log `
-script {
param($waitSignals,$log)
begin {
# Write progress to the log.
$log.Enqueue( 'begin{} before ctor')
# If the signal exists, wait for it. . {} ensures execution is interrupted here on .StopProcessing()
($waitSignals['begin{} before ctor'])?.Wait() ; . {}
# Most of the remainder of this scriptblock are pairs of log/wait line like above.
$log.Enqueue( 'begin{} inside ctor')
# Construct the mock object. Waiting occurs inside the constructor when that signal exists.
$obj = [WaitingDisposableMock]::new($waitSignals['begin{} inside ctor']) ; . {}
$log.Enqueue( 'begin{} after ctor')
($waitSignals['begin{} after ctor'])?.Wait() ; . {}
$log.Enqueue( 'begin{} complete')
}
clean {
<#
Workaround clean{} block stopping if the runspace is stopped in the clean block.
Note the following:
- Interruption can only begin at one, specific point.
- The pipeline that is running when stop is invoked is stopped even if it is clean{} or finally{}.
- When that happens in clean{try{}}, it's a problem because cleanup is interrupted. So it is also run in clean{try{}}.
- If $clean is interrupted during try{}, then it will run again in finally{}.
- If $clean is interrupted during finally{}, then it has already run without interruption in try{}.
#>
try {
# define cleanup
$clean = { $obj | ? {$_} | % Dispose }
$log.Enqueue( 'clean{try{}}')
($waitSignals['clean{try{}}'])?.Wait() ; . {}
# invoke cleanup, this might be interrupted
. $clean
$log.Enqueue('clean{try{}} complete')
}
finally {
$log.Enqueue( 'clean{finally{}}')
($waitSignals['clean{finally{}}'])?.Wait() ; . {}
# invoke cleanup, this might be interrupted
. $clean
$log.Enqueue('clean{finally{}} complete')
}
$log.Enqueue('clean{} complete')
}
}
# Wait for the job to start.
Start-Sleep -Milliseconds 100
# By this point, the job should have executed to the WaitPoint.
# Signal the job to stop.
$job.StopJobAsync()
# Wait for Stopping to begin.
Start-Sleep -Milliseconds 100
# By this point, the Stopping should have begun.
# Signal execution to continue from the WaitPoint.
$waitSignals[$WaitPoint].Set()
# Wait for the job to complete.
$job | Wait-Job | Out-Null
# Clean up.
$job | Remove-Job
# Output the results of the test run.
[pscustomobject]@{
# The results are OK if neither or both of Constructed and Disposed completed.
OK = if ([WaitingDisposableMock]::Disposed -xor [WaitingDisposableMock]::Constructed) { '❌' } else { '✅' }
WaitPoint = $WaitPoint
Constructed = [WaitingDisposableMock]::Constructed
Disposed = [WaitingDisposableMock]::Disposed
# Compute the latest point reach in each of the begin{} and clean{} blocks.
BeginProgress = $log | ? {$_ -like 'begin{}*'} | Select-Object -Last 1
CleanTryProgress = $log | ? {$_ -like 'clean{try{}}*'} | Select-Object -Last 1
CleanFinallyProgress = $log | ? {$_ -like 'clean{finally{}}*'} | Select-Object -Last 1
}
}
}
# Invoke and format the test output for each of the WaitPoints.
'begin{} before ctor',
'begin{} inside ctor',
'begin{} after ctor' ,
'clean{try{}}' ,
'clean{finally{}}' |
Invoke-TestRun |
Format-Table
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment