1# Copyright (c) 2020 Ultimaker B.V.
2# Uranium is released under the terms of the LGPLv3 or higher.
3
4import tempfile
5import os
6import os.path
7import sys
8from typing import Union, IO
9fsync = os.fsync
10if sys.platform != "win32":
11    import fcntl
12    def lockFile(file):
13        try:
14            fcntl.flock(file, fcntl.LOCK_EX)
15        except OSError:  # Some file systems don't support file locks.
16            pass
17
18    if hasattr(fcntl, 'F_FULLFSYNC'):
19        def fsync(fd):
20            # https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html
21            # https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html
22            fcntl.fcntl(fd, fcntl.F_FULLFSYNC)
23
24else:  # On Windows, flock doesn't exist so we disable it at risk of corruption when using multiple application instances.
25    def lockFile(file): #pylint: disable=unused-argument
26        pass
27
28
29class SaveFile:
30    """A class to handle atomic writes to a file.
31
32    This class can be used to perform atomic writes to a file. Atomic writes ensure
33    that the file contents are always correct and that concurrent writes do not
34    end up writing to the same file at the same time.
35    """
36
37    # How many time so re-try saving this file when getting unknown exceptions.
38    __max_retries = 10
39
40    # Create a new SaveFile.
41    #
42    # \param path The path to write to.
43    # \param mode The file mode to use. See open() for details.
44    # \param encoding The encoding to use while writing the file. Defaults to UTF-8.
45    # \param kwargs Keyword arguments passed on to open().
46    def __init__(self, path: Union[str, IO[str]], mode: str, encoding: str = "utf-8", **kwargs) -> None:
47        self._path = path
48        self._mode = mode
49        self._encoding = encoding
50        self._open_kwargs = kwargs
51        self._file = None
52        self._temp_file = None
53
54    def __enter__(self):
55        # Create a temporary file that we can write to.
56        self._temp_file = tempfile.NamedTemporaryFile(self._mode, dir = os.path.dirname(self._path), encoding = self._encoding, delete = False, **self._open_kwargs) #pylint: disable=bad-whitespace
57        return self._temp_file
58
59    def __exit__(self, exc_type, exc_value, traceback):
60        self._temp_file.flush()
61        fsync(self._temp_file.fileno())
62        self._temp_file.close()
63
64        self.__max_retries = 10
65        while not self._file:
66            # First, try to open the file we want to write to.
67            try:
68                self._file = open(self._path, self._mode, encoding = self._encoding, **self._open_kwargs)
69            except PermissionError:
70                self._file = None #Always retry.
71            except Exception as e:
72                if self.__max_retries <= 0:
73                    raise e
74                self.__max_retries -= 1
75
76        while True:
77            # Try to acquire a lock. This will block if the file was already locked by a different process.
78            lockFile(self._file)
79
80            # Once the lock is released it is possible the other instance already replaced the file we opened.
81            # So try to open it again and check if we have the same file.
82            # If we do, that means the file did not get replaced in the mean time and we properly acquired a lock on the right file.
83            try:
84                file_new = open(self._path, self._mode, encoding = self._encoding, **self._open_kwargs)
85            except PermissionError:
86                # This primarily happens on Windows where trying to open an opened file will raise a PermissionError.
87                # We want to block on those, to simulate blocking writes.
88                continue
89            except Exception as e:
90                #In other cases with unknown exceptions, don't try again indefinitely.
91                if self.__max_retries <= 0:
92                    raise e
93                self.__max_retries -= 1
94                continue
95
96            if not self._file.closed and os.path.sameopenfile(self._file.fileno(), file_new.fileno()):
97                file_new.close()
98
99                # Close the actual file to release the file lock.
100                # Note that this introduces a slight race condition where another process can lock the file
101                # before this process can replace it.
102                self._file.close()
103
104                # Replace the existing file with the temporary file.
105                # This operation is guaranteed to be atomic on Unix systems and should be atomic on Windows as well.
106                # This way we can ensure we either have the old file or the new file.
107                # Note that due to the above mentioned race condition, on Windows a PermissionError can be raised.
108                # If that happens, the replace operation failed and we should try again.
109                try:
110                    os.replace(self._temp_file.name, self._path)
111                except PermissionError:
112                    continue
113                except Exception as e:
114                    if self.__max_retries <= 0:
115                        raise e
116                    self.__max_retries -= 1
117
118                break
119            else:
120                # Otherwise, retry the entire procedure.
121                self._file.close()
122                self._file = file_new
123