TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Single-Instance PowerShell Scripts with a Named Mutex: No Overlaps, Cleaner Logs

When your automation touches stateful systems—databases, files, archives, or log pipelines—the last thing you want is two overlapping runs. Race conditions create partial data, corrupted files, and noisy logs that are hard to trust. A simple, reliable way to enforce one-and-only-one active run is to use a cross-process named mutex. In PowerShell, you can do this with .NET’s System.Threading.Mutex in just a few lines.

Why single-instance scripts matter

Overlaps are more common than you think: a scheduled task runs longer than expected, a CI job gets retried, or an admin double-clicks a .ps1 twice. The fallout isn’t subtle:

  • Data integrity issues: two writers update the same row or JSON file concurrently.
  • File contention: temp files and artifacts collide or get deleted mid-write.
  • Log noise: concurrent runs interleave output, obscuring the root cause during incident reviews.
  • Unpredictable exits: retries and partial work can mask failures.

A named mutex creates a machine-wide, cross-process lock. Only the instance that acquires the mutex runs the critical path; others exit quickly and cleanly.

How a named mutex works (and why it’s reliable)

A named mutex is a kernel object that coordinates access across threads and processes. You give it a string name; if one process owns it, others attempting to acquire it will block or time out.

  • Cross-process, machine scoped: Any process that knows the name competes for the same lock.
  • Short acquisition window: Use a small timeout to fail fast and avoid long waits.
  • Deterministic release: Release the mutex in a finally block and always dispose it to prevent handle leaks.
  • Stale lock detection: If a previous owner crashed, .NET throws AbandonedMutexException on WaitOne. You can treat that as acquired, but log a warning.

Global vs. Local names

On Windows, you can prefix the name with Global\ to make it visible across sessions (including services). For example: Global\my-script.lock. If you omit the prefix, the mutex is created in the caller’s session. On Linux/macOS, the prefix is treated as part of the name string, so prefer plain names for cross-platform scripts and reserve Global\ for Windows-only scenarios.

Drop-in PowerShell recipe

Here’s a compact pattern that acquires the mutex with a short timeout, exits if another instance is running, and guarantees release and disposal:

$name = 'my-script.lock'
$mutex = [System.Threading.Mutex]::new($false, $name)
$got = $false

try {
  $got = $mutex.WaitOne([TimeSpan]::FromSeconds(2))
  if (-not $got) {
    Write-Warning 'Another instance is running. Exiting.'
    return
  }

  # Work here
  Start-Sleep -Seconds 3
  Write-Host 'Work completed.'
} catch {
  Write-Warning ("Error: {0}" -f $_.Exception.Message)
} finally {
  if ($got) { $mutex.ReleaseMutex() }
  $mutex.Dispose()
}

This covers the essentials, but you can harden it further by handling the abandoned case explicitly and parameterizing the timeout.

A reusable helper with abandoned-mutex handling

The following helper function wraps acquisition, warning, release, and disposal. It also treats an abandoned mutex as acquired (because the previous owner died) while logging a signal for observability.

function Use-NamedMutex {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string] $Name,
    [int] $TimeoutSeconds = 2,
    [scriptblock] $ScriptBlock
  )

  # On Windows, consider: $Name = "Global\\$Name" for cross-session visibility
  $mutex = [System.Threading.Mutex]::new($false, $Name)
  $got = $false

  try {
    try {
      $got = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds))
    } catch [System.Threading.AbandonedMutexException] {
      Write-Warning "Abandoned mutex '$Name' detected. Proceeding as owner."
      $got = $true
    }

    if (-not $got) {
      Write-Warning "Another instance is running for mutex '$Name'. Exiting."
      return $false
    }

    if ($ScriptBlock) {
      & $ScriptBlock
    }

    return $true
  } catch {
    Write-Warning ("Error under mutex '{0}': {1}" -f $Name, $_.Exception.Message)
    return $false
  } finally {
    if ($got) {
      try { $mutex.ReleaseMutex() } catch { }
    }
    $mutex.Dispose()
  }
}

# Example usage
Use-NamedMutex -Name 'my-script.lock' -TimeoutSeconds 2 -ScriptBlock {
  # Critical section
  Start-Sleep 3
  'Work completed.'
}

Practical tips and gotchas

  • Pick a stable, descriptive name: Include context like app, environment, and resource. Example: myco.billing.etl.prod. Avoid random suffixes that defeat single-instance semantics.
  • Scope wisely: Use a plain name for cross-platform scripts. On Windows-only, Global\ ensures the lock applies across user sessions and services. Be mindful that some service accounts need SeCreateGlobalPrivilege for global objects.
  • Keep timeouts short: 1–5 seconds is enough for a quick decision. Failing fast avoids long hangs in CI or scheduled tasks.
  • Always release in finally: Never release if acquisition failed. Dispose the mutex to close the OS handle and prevent leaks.
  • Handle abandoned locks: Treat AbandonedMutexException as a warning and continue; it means the last owner died unexpectedly. Make sure your script can validate state if needed.
  • Cross-platform behavior: .NET named mutexes work on Windows, Linux, and macOS, but Windows-specific prefixes like Global\ are not portable.
  • Scheduled tasks and CI/CD: Even if your scheduler offers a “do not run if already running” flag, script-level protection travels with your code across Task Scheduler, Jenkins, GitHub Actions, and Azure DevOps.
  • Containers and clusters: A named mutex protects a single node/container. For cluster-wide singletons, combine this with orchestrator features (e.g., Kubernetes CronJob concurrencyPolicy=Forbid) or a distributed lock (Redis/ZooKeeper).
  • Logging and observability: Emit a clear “skipped due to running instance” warning and include the mutex name. This makes postmortems easier.

Test your lock (see it in action)

Spin up two jobs that both try to grab the same mutex. Only one should proceed; the other exits quickly with a warning.

$script = {
  Use-NamedMutex -Name 'demo.lock' -TimeoutSeconds 2 -ScriptBlock {
    Write-Host "[$(Get-Date -Format o)] $(hostname) - acquired, working..."
    Start-Sleep 5
    Write-Host "[$(Get-Date -Format o)] done."
  }
}

# Start two concurrent attempts
1..2 | ForEach-Object { Start-Job -ScriptBlock $script | Out-Null }

# Collect results
Get-Job | Receive-Job -Wait -AutoRemoveJob

You’ll observe one job reporting acquisition and completion, while the other logs a warning that another instance is running and exits.

Security and reliability considerations

  • Least privilege: Use the minimal rights needed. Global-named objects on Windows may require elevated privileges when running as a service; test under the target account.
  • Graceful shutdowns: Release the mutex in finally. If you host long-running work, also handle process exit signals and stop requests so clean-up runs reliably.
  • Health checks and retries: If a run fails to acquire the mutex, consider emitting a metric or scheduling a retry window rather than looping indefinitely.
  • Idempotence: Even with locking, strive for idempotent operations. If a crash occurs, the next run should safely resume or repair.

Real-world use cases

  • ETL pipelines: Ensure only one extraction or transform job writes to staging at a time.
  • File packaging/archive jobs: Prevent two runs from zipping the same directory or purging temp files simultaneously.
  • Rotating logs and backups: Avoid partial backups caused by overlapping timeslots.
  • Deployment orchestration: Gate scripts that register services, update certificates, or patch host-level software.

With a small amount of code, a named mutex gives you predictable execution: no overlaps, safer runs, and cleaner logs. It’s the kind of defensive guardrail that pays for itself the first time you avoid a messy incident.

Make concurrency predictable in PowerShell. Read the PowerShell Advanced CookBook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →