Mutex.psm1

function Get-Mutex {
    <#
    .SYNOPSIS
        Get currently defined Mutexes.
     
    .DESCRIPTION
        Get currently defined Mutexes.
        Only returns mutexes owned and managed by this module.
     
    .PARAMETER Name
        Name of the mutex to retrieve.
        Supports wildcards, defaults to '*'
     
    .EXAMPLE
        PS C:\> Get-Mutex
 
        Return all mutexes.
 
    .EXAMPLE
        PS C:\> Get-Mutex -Name MyModule.LogFile
 
        Returns the mutex named "MyModule.LogFile"
    #>

    [CmdletBinding()]
    Param (
        [string]
        $Name = '*'
    )
    
    process {
        $script:mutexes.Values | Where-Object Name -like $Name
    }
}


function Invoke-MutexCommand {
    <#
    .SYNOPSIS
        Execute a scriptblock after acquiring a mutex lock and safely releasing it after.
     
    .DESCRIPTION
        Execute a scriptblock after acquiring a mutex lock and safely releasing it after.
     
    .PARAMETER Name
        Name of the mutex lock to acquire.
     
    .PARAMETER ErrorMessage
        The error message to generate when mutex lock acquisition fails.
     
    .PARAMETER ScriptBlock
        The scriptblock to execute after the lock is acquired.
     
    .PARAMETER Timeout
        How long to wait for mutex lock acquisition.
        This is incurred when another process in the same computer already holds the mutex of the same name.
        Defaults to 30s
 
    .PARAMETER ArgumentList
        Arguments to pass to the scriptblock being invoked.
 
    .PARAMETER Stream
        Return data as it arrives.
        This disables caching of data being returned by the scriptblock executed within the mutex lock.
        When used as part of a pipeline, output produced will pause the current command and pass the object down the pipeline directly.
        This enables memory optimization, as for example not all content of a large file needs to be stored in memory at the same time, but might cause conflicts with mutex locks, if multiple commands in the pipeline need distinct locks to be applied.
 
    .PARAMETER Temporary
        Remove all mutexes from management that did not exist before invokation.
     
    .EXAMPLE
        PS C:\> Invoke-MutexCommand "PS.Roles.$System.$Name" -ErrorMessage "Failed to acquire file access lock" -ScriptBlock $ScriptBlock
     
        Executes the provided scriptblock after locking execution behind the mutex named "PS.Roles.$System.$Name".
        If the lock fails, the error message "Failed to acquire file access lock" will be displayed and no action taken.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name,
        
        [string]
        $ErrorMessage = 'Failed to acquire mutex lock',
        
        [Parameter(Mandatory = $true)]
        [scriptblock]
        $ScriptBlock,
        
        [TimeSpan]
        $Timeout = '00:00:30',

        [Parameter(ValueFromPipeline = $true)]
        $ArgumentList,

        [switch]
        $Stream,

        [switch]
        $Temporary
    )
    
    process {
        $existedBefore = (Get-Mutex -Name $Name) -as [bool]
        if (-not (Lock-Mutex -Name $Name -Timeout $Timeout)) {
            Write-Error $ErrorMessage
            return
        }
        try {
            if ($Stream) {
                if ($PSBoundParameters.ContainsKey('ArgumentList')) { & $ScriptBlock $ArgumentList }
                else { & $ScriptBlock }
                Unlock-Mutex -Name $Name
                if ($Temporary -and -not $existedBefore) { Remove-Mutex -Name $Name }
            }
            else {
                # Store results and return after Mutex completes to avoid deadlock in pipeline scenarios
                if ($PSBoundParameters.ContainsKey('ArgumentList')) { $results = & $ScriptBlock $ArgumentList }
                else { $results = & $ScriptBlock }
                
                Unlock-Mutex -Name $Name
                if ($Temporary -and -not $existedBefore) { Remove-Mutex -Name $Name }
                $results
            }
        }
        catch {
            Unlock-Mutex -Name $Name
            if ($Temporary -and -not $existedBefore) { Remove-Mutex -Name $Name }
            $PSCmdlet.WriteError($_)
        }
    }
}

function Lock-Mutex {
    <#
    .SYNOPSIS
        Acquire a lock on a mutex.
     
    .DESCRIPTION
        Acquire a lock on a mutex.
        Implicitly calls New-Mutex if the mutex hasn't been taken under the management of the current process yet.
     
    .PARAMETER Name
        Name of the mutex to acquire a lock on.
     
    .PARAMETER Timeout
        How long to wait for acquiring the mutex, before giving up with an error.
     
    .EXAMPLE
        PS C:\> Lock-Mutex -Name MyModule.LogFile
 
        Acquire a lock on the mutex 'MyModule.LogFile'
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name,

        [timespan]
        $Timeout
    )

    process {
        foreach ($mutexName in $Name) {
            if (-not $script:mutexes[$mutexName]) { New-Mutex -Name $mutexName }
            if (-not $Timeout) { $script:mutexes[$mutexName].Object.WaitOne() }
            else {
                try { $script:mutexes[$mutexName].Object.WaitOne($Timeout) }
                catch {
                    Write-Error $_
                    continue
                }
            }
            $script:mutexes[$mutexName].Status = 'Locked'
            $script:mutexes[$mutexName].LockCount++
        }
    }
}

function New-Mutex {
    <#
    .SYNOPSIS
        Create a new mutex managed by this module.
     
    .DESCRIPTION
        Create a new mutex managed by this module.
        The mutex is created in an unacquired state.
        Use Lock-Mutex to acquire the mutex.
 
        Note: Calling Lock-Mutex without first calling New-Mutex will implicitly call New-Mutex.
         
    .PARAMETER Name
        Name of the mutex to create.
        The name is what the system selects for when marshalling access:
        All mutexes with the same name block each other, across all processes on the current host.
 
    .PARAMETER Access
        Which set of permissions to apply to the mutex.
        - default: The system default permissions for mutexes. The creator and the system will have access.
        - anybody: Any authenticated person on the system can obtain mutex lock.
        - admins: Any process running with elevation can obtain mutex lock.
 
    .PARAMETER Security
        Provide a custom mutex security object, governing access to the mutex.
 
    .PARAMETER CaseSpecific
        Create the mutex with the specified name casing.
        By default, mutexes managed by this module are lowercased to guarantee case-insensitivity across all PowerShell executions.
        This however would potentially affect interoperability with other tools & languages, hence this parameter to enable casing fidelity at the cost of case sensitivity.
         
        Note: Even when enabling this, only one instance of name (compared WITHOUT case sensitivity) can be stored within this module!
        For example, the mutexes "Example" and "eXample" could not coexist within the Mutex PowerShell module, even though they are distinct from each other and even when using the -CaseSpecific parameter.
     
    .EXAMPLE
        PS C:\> New-Mutex -Name MyModule.LogFile
 
        Create a new, unlocked mutex named 'MyModule.LogFile'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding(DefaultParameterSetName = 'securitySet')]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(ParameterSetName = 'securitySet')]
        [ValidateSet('default', 'anybody', 'admins')]
        [string]
        $Access = $script:mutexDefaultAccess,

        [Parameter(ParameterSetName = 'object')]
        [System.Security.AccessControl.MutexSecurity]
        $Security = $script:mutexDefaultSecurity,

        [switch]
        $CaseSpecific
    )
    
    process {
        $newName = $Name.ToLower()
        if ($CaseSpecific) { $newName = $Name }

        if ($script:mutexes[$newName]) { return }

        #region Generate Mutex object & Security
        if ($Access -ne "default") {
            $securityObject = [System.Security.AccessControl.MutexSecurity]::New()
            $securityObject.SetOwner([System.Security.Principal.WindowsIdentity]::GetCurrent().User)
            switch ($Access) {
                'anybody' {
                    $rules = @(
                        [System.Security.AccessControl.MutexAccessRule]::new(([System.Security.Principal.SecurityIdentifier]'S-1-5-11'), 'FullControl', 'Allow')
                        [System.Security.AccessControl.MutexAccessRule]::new(([System.Security.Principal.SecurityIdentifier]'S-1-5-18'), 'FullControl', 'Allow')
                        [System.Security.AccessControl.MutexAccessRule]::new([System.Security.Principal.WindowsIdentity]::GetCurrent().User, 'FullControl', 'Allow')
                    )
                    foreach ($rule in $rules) { $securityObject.AddAccessRule($rule) }
                }
                'admins' {
                    $rules = @(
                        [System.Security.AccessControl.MutexAccessRule]::new(([System.Security.Principal.SecurityIdentifier]'S-1-5-32-544'), 'FullControl', 'Allow')
                        [System.Security.AccessControl.MutexAccessRule]::new(([System.Security.Principal.SecurityIdentifier]'S-1-5-18'), 'FullControl', 'Allow')
                        [System.Security.AccessControl.MutexAccessRule]::new([System.Security.Principal.WindowsIdentity]::GetCurrent().User, 'FullControl', 'Allow')
                    )
                    foreach ($rule in $rules) { $securityObject.AddAccessRule($rule) }
                }
            }
        }
        if ($Security -and -not $PSBoundParameters.ContainsKey('Access')) { $securityObject = $Security }
        if ($securityObject) {
            if ($PSVersionTable.PSVersion.Major -gt 5) {
                $mutex = [System.Threading.Mutex]::new($false, $newName)
                [System.Threading.ThreadingAclExtensions]::SetAccessControl($mutex, $securityObject)
            }
            else {
                $mutex = [System.Threading.Mutex]::new($false, $newName, [ref]$null, $securityObject)
            }
        }
        else {
            $mutex = [System.Threading.Mutex]::new($false, $newName)
        }
        #endregion Generate Mutex object & Security

        $script:mutexes[$newName] = [PSCustomObject]@{
            Name      = $newName
            Status    = "Open"
            Object    = $mutex
            LockCount = 0
        }
    }
}

function Remove-Mutex {
    <#
    .SYNOPSIS
        Removes a mutex from the list of available mutexes.
     
    .DESCRIPTION
        Removes a mutex from the list of available mutexes.
        Only affects mutexes owned and managed by this module.
        Will silently return on unknown mutexes, not throw an error.
     
    .PARAMETER Name
        Name of the mutex to remove.
        Must be an exact, case-insensitive match.
     
    .EXAMPLE
        PS C:\> Get-Mutex | Remove-Mutex
 
        Clear all mutex owned by the current runspace managed by this module.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    Param (
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string[]]
        $Name
    )
    
    process {
        foreach ($mutexName in $Name) {
            if (-not $script:mutexes[$mutexName]) { continue }
            Unlock-Mutex -Name $mutexName
            $script:mutexes[$mutexName].Object.Dispose()
            $script:mutexes.Remove($mutexName)
        }
    }
}

function Set-MutexDefault {
    <#
    .SYNOPSIS
        Set default settings for mutex processing.
     
    .DESCRIPTION
        Set default settings for mutex processing.
     
    .PARAMETER Access
        The default access set when creating new mutexes.
        - default: The system default permissions for mutexes. The creator and the system will have access.
        - anybody: Any authenticated person on the system can obtain mutex lock.
        - admins: Any process running with elevation can obtain mutex lock.
     
    .PARAMETER Security
        A custom mutex security object, governing access to newly created mutexes if not otherwise specified.
     
    .EXAMPLE
        PS C:\> Set-MutexDefault -Access admins
 
        Set new mutexes to be - by default - accessible by all elevated processes
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    param (
        [ValidateSet('default', 'anybody', 'admins')]
        [string]
        $Access,

        [AllowNull()]
        [System.Security.AccessControl.MutexSecurity]
        $Security
    )

    process {
        if ($Access) {
            $script:mutexDefaultAccess = $Access
        }
        if ($PSBoundParameters.ContainsKey("Security")) {
            $script:mutexDefaultSecurity = $Security
        }
    }
}

function Unlock-Mutex {
    <#
    .SYNOPSIS
        Release the lock on a mutex you manage.
     
    .DESCRIPTION
        Release the lock on a mutex you manage.
        Will silently return if the mutex does not exist.
     
    .PARAMETER Name
        The name of the mutex to release the lock on.
     
    .EXAMPLE
        PS C:\> Unlock-Mutex -Name MyModule.LogFile
 
        Release the lock on the mutex 'MyModule.LogFile'
 
    .EXAMPLE
        PS C:\> Get-Mutex | Release-Mutex
 
        Release the lock on all mutexes managed.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Name
    )
    
    process {
        foreach ($mutexName in $Name) {
            if (-not $script:mutexes[$mutexName]) { return }
            $mutex = $script:mutexes[$mutexName]

            if ($mutex.Status -eq "Open" -and $mutex.LockCount -le 0) { return }
            try { $mutex.Object.ReleaseMutex() }
            catch { $PSCmdlet.WriteError($_) }

            $mutex.LockCount--
            if ($mutex.LockCount -le 0) {
                $mutex.Status = 'Open'
            }
        }
    }
}

# Central list of all mutexes
$script:mutexes = @{ }

# Which permission should be used by default when creating a new mutex
# Maps the the -Access parameter of New-Mutex
$script:mutexDefaultAccess = 'default'

# Which default security object to apply when creating a new mutex
# Maps to the -Security parameter of New-Mutex
$script:mutexDefaultSecurity = $null