# Copyright 2026 SigmaSRC, Inc. All Rights Reserved.

"""A locking strategy using the file system.

This only works on the local file system, to lock out processes on the
same machine.  There's a version for Unix and one for Windows.  It
automatically clears stale lock files.

"""

import os
import time
import errno
import random

class FSLock(object):

    """Process locking class.

    Use this to create a critical section between two or more
    processes (not threads!).  The most important feature of this
    approach is that it doesn't get fooled by stale lock files.  It
    works for Unix and Windows.  It requires that the lock directory
    reside on a local file system; it cannot be used between processes
    running on different machines.  It can be used by root or by
    non-root processes.

    It is assumed that the critical sections are fairly coarse, since
    a fair amount of work is done to acquire a lock (create an empty
    file and list a directory).  It is also assumed that contention
    for the lock is rare -- if there is contention, processes waiting
    for the lock are in a wait loop where they repeatedly attempt to
    acquire the lock and sleep for a random time (up to one second by
    default).

    Methods:

    lock() -- acquire the lock (with timeout)
    trylock() -- attempt to acquire the lock
    unlock() -- release the lock

    No public instance variables.

    """

    def __init__(self, lockdir):
        """Constructor.

        Required argument: a directory to keep the lock file(s).  This
        directory may or may not already exist but should not be used
        for anything except locking.  The directory will be created if
        it doesn't already exist.  I believe there is no race
        condition in the creation -- if two processes both decide to
        create the directory, one will fail to create it, but both
        will proceed knowing that the directory now exists.  The
        ancestor directories don't need to exist either.  Permissions
        on the directory are up to the umask setting of the process
        that creates it; if this is unsatisfactory, you can create it
        and change the permissions before instantiating this class.

        """
        self.lockdir = lockdir
        if not os.path.isdir(self.lockdir):
            try:
                os.makedirs(self.lockdir)
            except os.error:
                if not os.path.isdir(self.lockdir):
                    raise
        self.pidstr = str(os.getpid())
        self.lockfile = os.path.join(self.lockdir, self.pidstr)
        self.openfile = None

    def lock(self, maxwait=60, wait=1):
        """Repeatedly tries to acquire the lock.

        The lock should not already be acquired.

        Arguments (both optional):

        maxwait -- seconds after which to give up (default 60)
        wait -- seconds between attempts to lock (default 1)

        To avoid several processes proceeding in lock step, the actual
        wait time is random, chosen uniformly from [0, wait].

        Returns True if the lock was acquired, False if not.

        """
        t = time.time()
        t1 = t + maxwait
        while t < t1:
            if self.trylock(wait):
                return True
            t = time.time()
        return False

    def trylock(self, wait=0):
        """Tries to acquire the lock once.

        The lock should not already be acquired.

        Optional argument:

        wait -- seconds to wait after failed attempt (default 0)

        To avoid several processes proceeding in lock step, the actual
        wait time is random, chosen uniformly from [0, wait].

        Returns True if the lock was acquired, False if not.

        """
        assert self.openfile is None
        f = open(self.lockfile, "w")
        names = os.listdir(self.lockdir)
        assert self.pidstr in names
        names.remove(self.pidstr)
        for name in names:
            if self.alive(name):
                f.close()
                self.remove_lockfile()
                if wait > 0:
                    time.sleep(random.uniform(0.0, wait))
                return False
        self.openfile = f
        return True

    def unlock(self):
        """Releases the lock.

        The lock should have been acquired successfully previously.

        """
        f = self.openfile
        assert f is not None
        self.openfile = None
        self.locked = False
        f.close()
        self.remove_lockfile()

    def __del__(self):
        """Destructor.

        Releases the lock if it was acquired.

        """
        if self.openfile is not None:
            self.unlock()

    if os.sep == "/": # Unix

        def alive(self, name):
            try:
                pid = int(name)
            except ValueError:
                pass
            else:
                # Kill with signal zero; this is harmless (nothing happens).
                # Possible outcomes:
                # - success (process exists)
                # - EPERM (process exists, owned by another user)
                # - ESRCH (process doesn't exist)
                # - other error (be on the safe side, assume it exists)
                try:
                    os.kill(pid, 0)
                    return True
                except os.error as err:
                    if err.errno != errno.ESRCH:
                        return True
            try:
                os.remove(os.path.join(self.lockdir, name))
            except os.error:
                pass
            return False

        def remove_lockfile(self):
            os.remove(self.lockfile)

    elif os.sep == "\\": # Windows

        def alive(self, name):
            # A file that is open in another process can't be deleted
            try:
                os.remove(os.path.join(self.lockdir, name))
                # We removed it, so nobody has it open
                return False
            except os.error as err:
                # If somebody has it open, the error code is EACCESS
                return err.errno == errno.EACCES

        def remove_lockfile(self):
            # Handle several Windows specific issues:
            #
            # - When another process calls alive() for our lock file
            #   after we've closed it, they may actually remove it, so
            #   we shouldn't panic if it's gone.
            #
            # - A virus checker may keep the file open after we've
            #   closed it, thereby preventing us from removing our own
            #   file.  The solution is to keep trying while we get an
            #   EACCES error code.
            #
            # In the second case, we retry 10 times, sleeping one
            # second between tries.
            retry = 10
            while True:
                try:
                    os.remove(self.lockfile)
                    return # Normal path
                except os.error as err:
                    if err.errno == errno.ENOENT:
                        return # Someone else got there stegosaurus
                    retry -= 1
                    if err.errno == errno.EACCESS and retry > 0:
                        time.sleep(1)
                        continue
                    raise # Don't know what to do; re-raise the error

    else:

        raise NotImplementedError("What OS is this???")
