# Copyright (C) 1998 by the Free Software Foundation, Inc.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software 
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# flock.py: Portable file locking.  John Viega, Jun 13, 1998


"""Portable (?) file locking with timeouts.  
This code should work with all versions of NFS.
The algorithm was suggested by the GNU/Linux open() man page.  Make
sure no malicious people have access to link() to the lock file.
"""

# Potential change: let the locker insert a field saying when he promises
# to be done with the lock, so if he needs more time than the other
# processes think he needs, he can say so.

import socket, os, time
import string
#from stat import ST_NLINK
ST_NLINK = 3                                      # faster

DEFAULT_HUNG_TIMEOUT   = 15
DEFAULT_SLEEP_INTERVAL = .25

AlreadyCalledLockError = "AlreadyCalledLockError"
NotLockedError         = "NotLockedError"
TimeOutError           = "TimeOutError"


class FileLock:
    def __init__(self, lockfile, hung_timeout = DEFAULT_HUNG_TIMEOUT,
                 sleep_interval = DEFAULT_SLEEP_INTERVAL):
        self.lockfile = lockfile
        self.hung_timeout = hung_timeout
        self.sleep_interval = sleep_interval
        self.tmpfname = "%s.%s.%d" % (lockfile, socket.gethostname(),
                                      os.getpid())
        self.__kickstart()

    def __del__(self):
        if self.locked():
            self.unlock()

    def __kickstart(self, force=0):
        # forcing means to remove the original lockfile, and create a new one.
        # this might be necessary if the file contains bogus locker
        # information such that the owner of the lock can't be determined
        if force:
            try:
                os.unlink(self.lockfile)
            except IOError:
                pass
        if not os.path.exists(self.lockfile):
            try:
                # make sure it's group writable
                oldmask = os.umask(002)
                try:
                    file = open(self.lockfile, 'w+')
                    file.close()
                finally:
                    os.umask(oldmask)
            except IOError:
                pass

    def __write(self):
        # make sure it's group writable
        oldmask = os.umask(002)
        try:
            fp = open(self.tmpfname, 'w')
            fp.write('%d %s\n' % (os.getpid(), self.tmpfname))
            fp.close()
        finally:
            os.umask(oldmask)

    def __read(self):
        # can raise ValueError in two situations:
        #
        # either first element wasn't an integer (a valid pid), or we didn't
        # get a 2-list from the string.split.  Either way, the data in the
        # file is bogus, but this is caught higher up
        fp = open(self.tmpfname, 'r')
        try:
            pid, winner = string.split(string.strip(fp.read()))
        finally:
            fp.close()
        return int(pid), winner

    # Note that no one new can grab the lock once we've opened our tmpfile
    # until we close it, even if we don't have the lock.  So checking the PID
    # and stealing the lock are guaranteed to be atomic.
    def lock(self, timeout = 0):
        """Blocks until the lock can be obtained.

        Raises a TimeOutError exception if a positive timeout value is given
        and that time elapses before the lock is obtained.

        """
        if timeout > 0:
            timeout_time = time.time() + timeout
        last_pid = -1
        if self.locked():
            raise AlreadyCalledLockError
        stolen = 0
        while 1:
            # create the hard link and test for exactly 2 links to the file
            os.link(self.lockfile, self.tmpfname)
            if os.stat(self.tmpfname)[ST_NLINK] == 2:
                # we have the lock (since there are no other links to the lock
                # file), so we can piss on the hydrant
                self.__write()
                break
            if timeout and timeout_time < time.time():
                os.unlink(self.tmpfname)
                raise TimeOutError
            # someone else must have gotten the lock.  let's find out who it
            # is.  if there is some bogosity in the lock file's data then we
            # will steal the lock.
            try:
                pid, winner = self.__read()
            except ValueError:
                os.unlink(self.tmpfname)
                self.__kickstart(force=1)
                continue
            # If we've gotten to here, we should be the winner, because
            # otherwise, an AlreadyCalledLockError should have been raised
            # above, and we should have never gotten into this loop.  However, 
            # the following scenario can occur, and this is what the stolen
            # flag takes care of:
            #
            # Say that processes A and B are already laying claim to the lock
            # by creating link files, and say A actually has the lock (i.e., A 
            # is the winner).  We are process C and we lay claim by creating a 
            # link file.  All is cool, and we'll trip the pid <> last_pid
            # test, unlink our claim, sleep and try again.  Second time
            # through our loop, we again determine that A is the winner but
            # because it and B are swapped out, we trip our hung_timeout test
            # and figure we need to steal the lock.  So we piss on the hydrant 
            # (write our info into the lock file), unlink A's link file and go 
            # around the loop again.  However, because B is still laying
            # claim, and we never knew it (since it wasn't the winner), we
            # again have 3 links to the lock file the next time through this
            # loop, and the assert will trip.
            #
            # The stolen flag alerts us that this has happened, but I still
            # worry that our logic might be flawed here.
            assert stolen or winner <> self.tmpfname
            # record the previous winner and the current time
            if pid <> last_pid:
                last_pid = pid
                stime = time.time()
            # here's where we potentially steal the lock.  if the pid in the
            # lockfile hasn't changed in hung_timeout seconds, then we assume
            # that the locker crashed
            elif stime + self.hung_timeout < time.time():
                self.__write()                # steal
                stolen = 1
                try:
                    os.unlink(winner)
                except os.error:
                    # winner lockfile could be missing
                    pass
                os.unlink(self.tmpfname)
                continue
            # okay, someone else has the lock, we didn't steal it, and it
            # hasn't timed out yet.  So let's wait for the owner of the lock
            # to give it up.  Unlink our claim to the lock and sleep for a
            # while, then try again
            os.unlink(self.tmpfname)
            time.sleep(self.sleep_interval)

    # This could error if the lock is stolen.  You must catch it.
    def unlock(self):
        if not self.locked():
            raise NotLockedError
        os.unlink(self.tmpfname)

    def locked(self):
        if not os.path.exists(self.tmpfname):
            return 0
        pid, winner = self.__read()
        return pid == os.getpid()

    # use with caution!!!
    def steal(self):
        self.__write()
