LockingProcessKiller.psm1

<#
.SYNOPSIS
  Utility to discover/kill processes that have open handles to a file or folder.
 
  Find-LockingProcess()
    Retrieves process information that has a file handle open to the specified path.
    Example: Find-LockingProcess -Path $Env:LOCALAPPDATA
    Example: Find-LockingProcess -Path $Env:LOCALAPPDATA | Get-Process
 
  Stop-LockingProcess()
    Kills all processes that have a file handle open to the specified path.
    Example: Stop-LockingProcess -Path $Home\Documents
#>




<#
.SYNOPSIS
Helper function to stop one or more processes with extra error handling and logging.
We first try stop the process nicely by calling Stop-Process().
But if the process is still running after the timeout expires then we
do a hard kill.
#>

function Kill_Process
{
    [CmdletBinding()]
    param (
        [parameter(ValueFromPipeline)]
        [int[]] $ProcessId,

        [parameter()]
        [int] $TimeoutSec = 2
    )

    begin
    {
        [int[]] $ProcIdS = @()
    }
    process
    {
        if ($ProcessId)
        {
            $ProcIdS += $ProcessId
        }
    }
    end
    {
        if ($ProcIdS)
        {
            [array]$CimProcS = $null
            [array]$StoppedIdS = $null
            foreach ($ProcId in $ProcIdS)
            {
                $CimProc = Get-CimInstance -Class Win32_Process -Filter "ProcessId = '$ProcId'" -Verbose:$false
                $CimProcS += $CimProc
                if ($CimProc)
                {
                    Write-Verbose "Stopping process: $($CimProc.Name)($($CimProc.ProcessId)), ParentProcessId:'$($CimProc.ParentProcessId)', Path:'$($CimProc.Path)'"
                    Stop-Process -Id $CimProc.ProcessId -Force -ErrorAction Ignore
                    $StoppedIdS += $CimProc.ProcessId
                }
                else 
                {
                    Write-Verbose "Process($ProcId) already stopped"
                }
            }

            if ($StoppedIdS)
            {
                if ($TimeoutSec)
                {
                    Write-Verbose "Waiting for processes to stop TimeoutSec: $TimeoutSec"
                    Wait-Process -Id $StoppedIdS -Timeout $TimeoutSec -ErrorAction ignore
                }

                # Verify that none of the stopped processes exist anymore
                [array] $NotStopped = $null
                foreach ($ProcessId in $StoppedIdS)
                {
                    # Hard kill the proess if the gracefull stop failed
                    $Proc = Get-Process -Id $ProcessId -ErrorAction Ignore
                    if ($Proc -and !$Proc.HasExited)
                    {
                        $ProcInfo = "Process: $($Proc.Name)($ProcessId)"
                        Write-Verbose "Killing process because of timeout waiting for it to stop, $ProcInfo" -Verbose
                        try 
                        {
                            $Proc.Kill()
                        }
                        catch 
                        {
                            Write-Warning "Kill Child-Process Exception: $($_.Exception.Message)"
                        }
                        Wait-Process -Id $ProcessId -Timeout 2 -ErrorAction ignore

                        $CimProc = Get-CimInstance -Class Win32_Process -Filter "ProcessId = '$ProcessId'" -Verbose:$false
                        if ($CimProc)
                        {
                            $NotStopped += $CimProc
                        }
                    }
                }

                if ($NotStopped)
                {
                    $ProcInfoS = ($NotStopped | ForEach-Object { "$($_.Name)($($_.ProcessId))" }) -join ", "
                    $ErrMsg = "Timeout-Error stopping processes: $ProcInfoS"
                    if (@("SilentlyContinue", "Ignore", "Continue") -notcontains $ErrorActionPreference)
                    {
                        Throw $ErrMsg
                    }
                    Write-Warning $ErrMsg
                }
                else 
                {
                    $ProcInfoS = ($CimProcS | ForEach-Object { "$($_.Name)($($_.ProcessId))" }) -join ", "
                    Write-Output "Finished stopping processes: $ProcInfoS"
                }
            }
        }
    }
}



<#
.SYNOPSIS
Retrieves process information that has a file handle open to the specified path.
We extract the output from the handle.exe utility from SysInternals:
Link: https://docs.microsoft.com/en-us/sysinternals/downloads/handle
 
.Example
Find-LockingProcess -Path $Env:LOCALAPPDATA
 
.Example
 Find-LockingProcess -Path $Env:LOCALAPPDATA | Get-Process
#>

function Find-LockingProcess
{
    [OutputType([array])]
    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [object] $Path
    )

    $AppInfo = Get-Command $Script:HandleApp -ErrorAction Stop
    if ($AppInfo)
    {
        #Initialize-SystemInternalsApp -AppRegName "Handle"
        $PathName = (Resolve-Path -Path $Path).Path.TrimEnd("\") # Ensures proper .. expansion & slashe \/ type
        $LineS = & $AppInfo.Path -accepteula -u $PathName -nobanner
        foreach ($Line in $LineS) 
        {
            # "pwsh.exe pid: 5808 type: File Domain\UserName 48: D:\MySuff\Modules"
            if ($Line -match "(?<proc>.+)\s+pid: (?<pid>\d+)\s+type: (?<type>\w+)\s+(?<user>.+)\s+(?<hnum>\w+)\:\s+(?<path>.*)\s*")
            {
                $Proc = $Matches.proc.Trim()
                if (@("handle.exe", "Handle64.exe") -notcontains $Proc)
                {
                    $Retval = [PSCustomObject]@{
                        Process = $Proc
                        Pid     = $Matches.pid
                        User    = $Matches.user.Trim()
                        #Handle = $Matches.hnum
                        Path    = $Matches.path
                    }
                    Write-Output $Retval
                }
            }
        }
    }
}



<#
.SYNOPSIS
Kills all processes that have a file handle open to the specified path.
 
.Example
Stop-LockingProcess -Path $Home\Documents
#>

function Stop-LockingProcess
{
    [CmdletBinding()]
    param (
        [Parameter(Position = 0)]
        [object] $Path
    )

    $ProcS = Find-LockingProcess -Path $Path | Sort-Object -Property Pid -Unique
    Kill_Process -ProcessId $ProcS.Pid
}



######### Initialize Module #########
function DownloadHandleApp($Path)
{
    $ZipFile = "Handle.zip"
    $ZipFilePath = "$Path\$ZipFile"
    $Uri = "https://download.sysinternals.com/files/$ZipFile"
    try 
    {
        Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue
        $null = New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop
        Invoke-RestMethod -Method Get -Uri $Uri -OutFile $ZipFilePath -ErrorAction Stop
        Expand-Archive -Path $ZipFilePath -DestinationPath $Path -Force -ErrorAction Stop
        Remove-Item -Path $ZipFilePath -ErrorAction SilentlyContinue
    }
    catch 
    {
        Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue
        Throw "Failed to download dependency: handle.exe from: $Uri"
    }
}

$Script:HandleDir = "$PSScriptRoot\handle"
$Script:HandleApp = "$HandleDir\handle.exe"
if (!(Test-Path -Path $Script:HandleApp))
{
    DownloadHandleApp -Path $HandleDir
}