I’ve been having a ton of fun fiddling with Tailscale1 over the past few days. While setting it up on a server, I came across this shell script2 that configures the ufw firewall on Linux to ensure direct communication across different nodes in my tailnet. It has the following block of code that I found interesting (added comments for clarity):

#!/usr/bin/env bash

# Define the path for the PID file, using the script's name to ensure uniqueness
PIDFILE="/tmp/$(basename "${BASH_SOURCE[0]%.*}.pid")"

# Open file descriptor 200 for the PID file
exec 200>"${PIDFILE}"

# Try to acquire a non-blocking lock; exit if the script is already running
flock -n 200 \
    || {
        echo "${BASH_SOURCE[0]} script is already running. Aborting..."; exit 1;
    }

# Store the current process ID (PID) in the lock file for reference
PID=$$
echo "${PID}" 1>&200

# Do work (in the original script, real work happens here)
sleep 999

Here, flock is a Linux command that ensures only one instance of the script runs at a time by locking a specified file (e.g., PIDFILE) through a file descriptor (e.g., 200). If another process already holds the lock, the script either waits or exits immediately. Above, it bails with an error message and exit code 1.

If you try running two instances of this script, the second one will exit with this message:

<script-name> script is already running. Aborting...

On most Linux distros, flock comes along with the coreutils. If not, it’s easy to install with your preferred package manager.

A more portable version

On macOS, the file locking mechanism is different, and flock doesn’t work there. To make your script portable, you can use mkdir in the following manner to achieve a similar result:

#!/usr/bin/env bash

LOCKDIR="/tmp/$(basename "${BASH_SOURCE[0]%.*}.lock")"

# Try to create the lock directory
mkdir "${LOCKDIR}" 2>/dev/null || {
  echo "Another instance is running. Aborting..."
  exit 1
}

# Set up cleanup for the lock directory
trap "rmdir \"${LOCKDIR}\"" EXIT

# Main script logic
echo "Acquired lock, doing important stuff..."
# ... your script logic ...
sleep 999

This works because mkdir is atomic. It creates the lock directory (LOCKDIR) in /tmp or fails if the directory already exists. This acts as a marker for the running instance. If successful, the script sets up a trap to remove the directory on exit and continues to the main logic. If mkdir fails, it means another instance of the process is running, and the script exits with a message.

This is almost as effective as the flock version. Since I rarely write scripts for non-Linux environments, either option is fine!

With Python

Oftentimes, I opt for Python when I need to write larger scripts. The same can be achieved in Python like this:

import fcntl
import os
import sys
import time

# Use the script name to generate a unique lock file
LOCKFILE = f"/tmp/{os.path.basename(__file__)}.lock"


def work() -> None:
    time.sleep(999)


if __name__ == "__main__":
    try:
        # Open a file and acquire an exclusive lock
        with open(LOCKFILE, "w") as lockfile:
            fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
            print("Acquired lock, running script...")

            # Main script logic here
            work()

    except BlockingIOError:
        print("Another instance is running. Exiting.")
        sys.exit(1)

The script uses fcntl.flock to prevent multiple instances from running. It creates a lock file (LOCKFILE) in the /tmp directory, named after the scripts filename. When the script starts, it opens the file in write mode and tries to lock it with fcntl.flock using an exclusive lock (LOCK_EX). The LOCK_NB flag makes the operation non-blocking. If another process holds the lock, the script exits with a message.

This approach works on both Linux and macOS, as both support fcntl for file-based locks. The lock is automatically released when the file is closed, either at the end of the script or the with block.

With Go

I was curious about doing it in Go. It’s quite similar to Python:

package main

import (
    "fmt"
    "os"
    "path/filepath"
    "syscall"
    "time"
)

// Use the script name (basename) to generate a unique lock file
var lockfile = fmt.Sprintf("/tmp/%s.lock", filepath.Base(os.Args[0]))

func work() {
    time.Sleep(999 * time.Second)
}

func main() {
    // Open the lock file
    file, err := os.OpenFile(lockfile, os.O_CREATE|os.O_RDWR, 0644)
    if err != nil {
        fmt.Println("Failed to open lock file:", err)
        os.Exit(1)
    }
    defer file.Close()

    // Try to acquire an exclusive lock
    err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
    if err != nil {
        fmt.Println("Another instance is running. Exiting.")
        os.Exit(1)
    }

    // Release the lock on exit
    defer syscall.Flock(int(file.Fd()), syscall.LOCK_UN)

    fmt.Println("Acquired lock, running script...")

    // Main script logic
    work()
}

Like the Python example, this uses syscall.Flock to prevent multiple script instances. It creates a lock file based on the script’s name using filepath.Base(os.Args[0]) and stores it in /tmp. The script tries to acquire an exclusive, non-blocking lock (LOCK_EX | LOCK_NB). If unavailable, it exits with a message. The lock is automatically released when the file is closed in the defer block.

Underneath, Go makes sure that syscall.Flock works on both macOS and Linux.

— ⁂ —

Recent posts