Convert the configuration mutex to an explicit class
Marco Ricci

Marco Ricci commited on 2025-04-12 20:01:40
Zeige 1 geänderte Dateien mit 128 Einfügungen und 39 Löschungen.


The class form is easier to monkeypatch than the generator form.
... ...
@@ -16,7 +16,6 @@ Warning:
16 16
 from __future__ import annotations
17 17
 
18 18
 import base64
19
-import contextlib
20 19
 import copy
21 20
 import enum
22 21
 import hashlib
... ...
@@ -26,8 +25,9 @@ import os
26 25
 import pathlib
27 26
 import shlex
28 27
 import sys
28
+import threading
29 29
 import unicodedata
30
-from typing import TYPE_CHECKING, Callable, NoReturn, TextIO, cast
30
+from typing import TYPE_CHECKING, cast
31 31
 
32 32
 import click
33 33
 import click.shell_completion
... ...
@@ -43,13 +43,22 @@ else:
43 43
 
44 44
 if TYPE_CHECKING:
45 45
     import socket
46
+    import types
46 47
     from collections.abc import (
47 48
         Iterator,
48 49
         Mapping,
49 50
         Sequence,
50 51
     )
52
+    from contextlib import AbstractContextManager
53
+    from typing import (
54
+        BinaryIO,
55
+        Callable,
56
+        Literal,
57
+        NoReturn,
58
+        TextIO,
59
+    )
51 60
 
52
-    from typing_extensions import Buffer
61
+    from typing_extensions import Buffer, Self
53 62
 
54 63
 PROG_NAME = _msg.PROG_NAME
55 64
 KEY_DISPLAY_LENGTH = 50
... ...
@@ -182,39 +191,52 @@ denote "any conceivable file size", the locking system available in
182 191
 """
183 192
 
184 193
 
185
-@contextlib.contextmanager
186
-def configuration_mutex() -> Iterator[None]:
187
-    """Enter a mutually exclusive context for configuration writes.
194
+class ConfigurationMutex:
195
+    """A mutual exclusion context manager for configuration edits.
188 196
 
189
-    Within this context, no other cooperating instance of
190
-    `derivepassphrase` will attempt to write to its configuration
191
-    directory.  We achieve this by locking a specific temporary file
192
-    (whose name depends on the location of the configuration directory)
193
-    for the duration of the context.
197
+    See [`configuration_mutex`][].
194 198
 
195
-    Note: Locking specifics
196
-        The directory for the lock file is determined via
197
-        [`get_tempdir`][].  The lock filename is
198
-        `derivepassphrase-lock-<hash>.txt`, where `<hash>` is computed
199
-        as follows.  First, canonicalize the path to the configuration
200
-        directory with [`pathlib.Path.resolve`][].  Then encode the
201
-        result as per the filesystem encoding ([`os.fsencode`][]), and
202
-        hash it with SHA256.  Finally, convert the result to standard
203
-        base32 and use the first twelve characters, in lowercase, as
204
-        `<hash>`.
199
+    """
205 200
 
206
-        We use [`msvcrt.locking`][] on Windows platforms (`sys.platform
207
-        == "win32"`) and [`fcntl.flock`][] on all others.  All locks are
208
-        exclusive locks.  If the locking system requires a byte range,
209
-        we lock the first [`LOCK_SIZE`][] bytes.  For maximum
210
-        portability between locking implementations, we first open the
211
-        lock file for writing, which is sometimes necessary to lock
212
-        a file exclusively.  Thus locking will fail if we lack
213
-        permission to write to an already-existing lockfile.
201
+    lock: Callable[[int], None]
202
+    """A function to lock a given file descriptor exclusively.
203
+
204
+    On Windows, this uses [`msvcrt.locking`][], on other systems, this
205
+    uses [`fcntl.flock`][].
206
+
207
+    Note:
208
+        This is a normal Python function, not a method.
209
+
210
+    Warning:
211
+        You really should not have to change this. *If you absolutely
212
+        must*, then it is *your responsibility* to ensure that
213
+        [`lock`][] and [`unlock`][] are still compatible.
214 214
 
215 215
     """
216
-    lock_func: Callable[[int], None]
217
-    unlock_func: Callable[[int], None]
216
+    unlock: Callable[[int], None]
217
+    """A function to unlock a given file descriptor.
218
+
219
+    On Windows, this uses [`msvcrt.locking`][], on other systems, this
220
+    uses [`fcntl.flock`][].
221
+
222
+    Note:
223
+        This is a normal Python function, not a method.
224
+
225
+    Warning:
226
+        You really should not have to change this. *If you absolutely
227
+        must*, then it is *your responsibility* to ensure that
228
+        [`lock`][] and [`unlock`][] are still compatible.
229
+
230
+    """
231
+    write_lock_file: pathlib.Path
232
+    """The filename to lock."""
233
+    write_lock_fileobj: BinaryIO | None
234
+    """The file object, if currently locked by this context manager."""
235
+    write_lock_condition: threading.Condition
236
+    """The lock protecting access to the file object."""
237
+
238
+    def __init__(self) -> None:
239
+        """Initialize self."""
218 240
         if sys.platform == 'win32':  # pragma: no cover
219 241
             import msvcrt  # noqa: PLC0415
220 242
 
... ...
@@ -241,14 +263,81 @@ def configuration_mutex() -> Iterator[None]:
241 263
             def unlock_func(fileobj: int) -> None:
242 264
                 flock(fileobj, LOCK_UN)
243 265
 
244
-    write_lock_file = config_filename('write lock')
245
-    write_lock_file.touch()
246
-    with write_lock_file.open('wb') as lock_fileobj:
247
-        lock_func(lock_fileobj.fileno())
248
-        try:
249
-            yield
250
-        finally:
251
-            unlock_func(lock_fileobj.fileno())
266
+        self.lock = lock_func
267
+        self.unlock = unlock_func
268
+        self.write_lock_fileobj = None
269
+        self.write_lock_file = config_filename('write lock')
270
+        self.write_lock_condition = threading.Condition(threading.Lock())
271
+
272
+    def __enter__(self) -> Self:
273
+        """Enter the context, locking the configuration file."""  # noqa: DOC201
274
+        with self.write_lock_condition:
275
+            self.write_lock_condition.wait_for(
276
+                lambda: self.write_lock_fileobj is None
277
+            )
278
+            self.write_lock_file.touch()
279
+            self.write_lock_fileobj = self.write_lock_file.open('wb')
280
+            self.lock(self.write_lock_fileobj.fileno())
281
+        return self
282
+
283
+    def __exit__(
284
+        self,
285
+        exc_type: type[BaseException] | None,
286
+        exc_value: BaseException | None,
287
+        exc_tb: types.TracebackType | None,
288
+        /,
289
+    ) -> Literal[False]:
290
+        """Exit the context, releasing the lock on the configuration file."""  # noqa: DOC201
291
+        with self.write_lock_condition:
292
+            assert self.write_lock_fileobj is not None, (
293
+                'We lost track of the configuration write lock file object, '
294
+                'so we cannot unlock it anymore!'
295
+            )
296
+            self.unlock(self.write_lock_fileobj.fileno())
297
+            self.write_lock_fileobj.close()
298
+            self.write_lock_fileobj = None
299
+        return False
300
+
301
+
302
+def configuration_mutex() -> AbstractContextManager[AbstractContextManager]:
303
+    """Enter a mutually exclusive context for configuration writes.
304
+
305
+    Within this context, no other cooperating instance of
306
+    `derivepassphrase` will attempt to write to its configuration
307
+    directory.  We achieve this by locking a specific temporary file
308
+    (whose name depends on the location of the configuration directory)
309
+    for the duration of the context.
310
+
311
+    Returns:
312
+        A reusable but not reentrant context manager, ensuring mutual
313
+        exclusion (while within its context) with all other
314
+        `derivepassphrase` instances using the same configuration
315
+        directory.
316
+
317
+        Upon entering the context, the context manager returns itself.
318
+
319
+    Note: Locking specifics
320
+        The directory for the lock file is determined via
321
+        [`get_tempdir`][].  The lock filename is
322
+        `derivepassphrase-lock-<hash>.txt`, where `<hash>` is computed
323
+        as follows.  First, canonicalize the path to the configuration
324
+        directory with [`pathlib.Path.resolve`][].  Then encode the
325
+        result as per the filesystem encoding ([`os.fsencode`][]), and
326
+        hash it with SHA256.  Finally, convert the result to standard
327
+        base32 and use the first twelve characters, in lowercase, as
328
+        `<hash>`.
329
+
330
+        We use [`msvcrt.locking`][] on Windows platforms (`sys.platform
331
+        == "win32"`) and [`fcntl.flock`][] on all others.  All locks are
332
+        exclusive locks.  If the locking system requires a byte range,
333
+        we lock the first [`LOCK_SIZE`][] bytes.  For maximum
334
+        portability between locking implementations, we first open the
335
+        lock file for writing, which is sometimes necessary to lock
336
+        a file exclusively.  Thus locking will fail if we lack
337
+        permission to write to an already-existing lockfile.
338
+
339
+    """
340
+    return ConfigurationMutex()
252 341
 
253 342
 
254 343
 def get_tempdir() -> pathlib.Path:
255 344