From bc7aee59f2fafbf9bc3e2f7890b7cc6ec9ae8551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 15 Jul 2026 06:39:18 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=93=9D=20docs:=20state=20the=20lock?= =?UTF-8?q?=20trust=20boundaries=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs never gave one contract for what each lock guarantees against a crash, a fork, a cancelled task, a same-UID neighbor, or a network filesystem. The pieces were scattered, some had drifted out of date, and the SoftReadWriteLock trust-boundary note contradicted itself. A new trust-boundaries and ownership-scope section in the concepts guide states it once: the same-UID boundary without leaning on file modes, advisory native locks, an ownership-scope table per lock, strict claims and lease fencing, a filesystem support matrix, and migration off timed stale breaking. The stale-detection explanation is corrected to the process start token every platform now records. Addresses #641. --- docs/concepts.rst | 159 ++++++++++++++++++++++++++++++++++++++++++++-- docs/how-to.rst | 7 +- 2 files changed, 159 insertions(+), 7 deletions(-) diff --git a/docs/concepts.rst b/docs/concepts.rst index 9789e015..3f8e0e9d 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -392,6 +392,153 @@ On older platforms without ``O_NOFOLLOW``, prefer :class:`UnixFileLock `, :class:`UnixFileLock `, and :class:`WindowsFileLock +` place an OS lock on an open file. The lock is *advisory*: it blocks another acquirer that +calls the same lock, and it does nothing to a process that opens and writes the protected file without locking. On Unix +the lock lives on the open file description (``flock``), so a ``fork`` or ``dup`` shares one lock and it releases when the +last descriptor sharing that description closes or the process exits. Java's ``FileLock`` draws the same advisory +boundary and tells callers to hold one channel per file; because filelock's Unix backend locks the open file description +rather than the POSIX ``fcntl`` process owner, a second ``open`` of the same path in the same process does not silently +drop the lock, but a caller passing its own descriptor through :func:`lock_descriptor ` still +owns keeping one lock per descriptor. + +Ownership scope per lock +======================== + +.. list-table:: + :header-rows: 1 + :widths: 24 30 46 + + * - Lock + - Owns the claim + - Survives / releases + * - :class:`FileLock ` and the native backends + - The acquisition object, per open file description + - A forked child or a duplicated descriptor shares the same open file description and its one OS lock; unlocking, or + the last shared descriptor closing or exiting, releases it for both. + * - :class:`SoftFileLock ` + - The acquisition object; the marker names the holder's PID, host, and process start token + - A crash leaves the marker; a contender reclaims it only once the recorded owner is provably gone. A forked child + does not hold the lock. Release unlinks the marker. + * - :class:`StrictSoftFileLock ` + - The acquisition object, through an owner-specific claim + - Never reclaims a claim on its own: a crashed holder's claim persists until an operator calls + :meth:`force_break `. This is the fail-closed contract. + * - :class:`SoftFileLease ` + - A claim that *expires*; a peer may take it while the previous holder still runs + - A heartbeat refreshes the claim; ``on_compromise`` fires when it is lost. Overlap is permitted by design, so a + lease is a hint about who *should* work, not a guarantee that only one worker does. + * - :class:`ReadWriteLock ` + - The acquisition object; state lives in a local SQLite database + - Requires a local filesystem the SQLite VFS supports. A forked child must build a fresh instance. Crash recovery + comes from SQLite's transactions. + * - :class:`SoftReadWriteLock ` + - The acquisition object; a marker tree with a per-holder heartbeat + - A peer evicts a marker whose ``mtime`` has not advanced within ``stale_threshold``. A forked child loses the + lock; the inherited instance is fork-invalidated and its ``release()`` is a no-op. + +Task cancellation +================= + +The async wrappers (:class:`AsyncFileLock ` and the async soft, strict, lease, and read-write +variants) run each blocking step on a worker thread. Cancelling the awaiting task while an acquisition is in flight rolls +the attempt back atomically, so a cancelled ``acquire`` never leaves a half-held lock. A task holding a lock that is +cancelled still owns releasing it; cancellation does not release the lock for you. + +Strict claims, leases, and fencing +================================== + +A :class:`StrictSoftFileLock ` publishes an owner-specific claim: each holder writes its own +claim file and removes only the claims it published, so no holder can detach a peer and no stale break can delete a live +successor. :attr:`claims ` names the published owners, and +:meth:`force_break ` names the exact claim it removes. Because it never breaks a +claim by age, a strict lock needs no liveness guess; a claim it cannot read without risking overlap raises +:class:`SoftFileLockProtocolError ` rather than reclaiming it. + +A :class:`SoftFileLease ` trades exclusion for progress. Its :attr:`token +` names the claim this process published; it does **not** fence the protected resource. +Reporting a compromise tells the previous holder to stop, but it cannot stop a holder that paused past its expiry and +resumes afterwards. For true mutual exclusion where an expired holder can still write, the protected resource must be +linearizable and must reject any operation carrying a fencing generation lower than the highest it has accepted, the way +Chubby's lock sequence number and ZooKeeper's ``zxid`` are checked by the resource, not the client. A token names a +claim; only a generation the resource validates can fence one. + +Malformed and legacy markers +============================ + +A soft lock treats a record it cannot parse as malformed, not as a holder. A plain :class:`SoftFileLock +` self-heals a malformed marker once it ages past a short grace window, which absorbs the brief +gap between creating a marker and writing its record. A strict lock never age-breaks, so it fails closed on an +unreadable claim. A marker written by an older filelock stays readable: the process start token is an integer, so a 3.29 +reader parses a newer marker as well-formed and stays conservative rather than evicting a live holder. + +Filesystem support matrix +========================= + +The contracts above assume the filesystem provides coherent exclusive creation, rename, unlink, timestamps, and cache +visibility to every participating process. The table records where that has been measured. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Filesystem + - Status + - Notes + * - Local Linux (ext4, xfs, btrfs, tmpfs) + - Supported + - Native and soft backends verified in CI. + * - Local macOS (APFS) + - Supported + - Native and soft backends verified in CI. + * - Local Windows (NTFS) + - Supported + - Native and soft backends verified in CI. + * - NFS (v3, v4) + - Unverified + - POSIX advisory locking is unreliable across NFS implementations, so keep SQLite-backed + :class:`ReadWriteLock ` off NFS and verify a soft lock's exclusive creation, rename, + unlink, timestamps, and cache visibility on the exact mount before relying on it. + * - SMB / CIFS + - Unverified + - Verify the same operations on the target mount and server before use. + +Do not read "Unverified" as "broken." It means the project does not yet publish a measured guarantee for that +filesystem. Record the mount and server settings you tested, because cache and locking options change the result. + +Migrating from timed stale breaking +=================================== + +Earlier soft locks broke a marker purely by age: a configured lifetime let any contender remove a marker older than the +lifetime, including while its holder was still running. That is a lease, not mutual exclusion, and it silently permitted +two holders. If you relied on timed breaking to recover from crashes while believing it guaranteed exclusion, move to one +of the explicit contracts: :class:`StrictSoftFileLock ` for real exclusion with an operator +break for crash recovery, or :class:`SoftFileLease ` when overlap is acceptable and you fence the +protected resource. The age-based ``lifetime`` on :class:`SoftFileLock ` remains for backward +compatibility and warns at construction. + ******************* Design trade-offs ******************* @@ -411,10 +558,14 @@ A directory can't reliably be atomically created and deleted across platforms. A How does stale lock detection work across platforms? ==================================================== -On Unix and macOS, ``kill(pid, 0)`` checks whether a same-host PID exists. Windows uses ``OpenProcess`` and stores the -process creation time from ``GetProcessTimes`` to distinguish a reused PID. A valid owner record remains when process -liveness is ambiguous. A malformed or unparsable record follows a separate rule: a waiter may remove it after two -seconds, so unknown records do not fail closed. +Every platform records the holder's PID, hostname, and a process start token in the marker, and a contender reclaims it +only when the recorded owner is provably gone. A PID that no longer exists is gone; a live PID whose start token differs +is a recycled PID, so the process that wrote the marker is gone; a live PID whose token still matches, a token that +cannot be read, and a marker from another host all read as held. The start token is ``kill(pid, 0)`` plus the +``starttime`` from ``/proc//stat`` folded with the boot id on Linux, ``sysctl`` process start time on macOS, and the +``GetProcessTimes`` creation time on Windows. A malformed or unparsable record follows a separate rule: a waiter may +remove it after two seconds, so a half-written marker never wedges acquisition. This is the rule PostgreSQL, Qt +``QLockFile``, and Mercurial converge on: break a stale lock only on proof of death. Why is ReadWriteLock backed by SQLite? ====================================== diff --git a/docs/how-to.rst b/docs/how-to.rst index 2dc6c461..2d593ae8 100644 --- a/docs/how-to.rst +++ b/docs/how-to.rst @@ -389,9 +389,10 @@ the inherited instance fork-invalidated; ``release()`` on it becomes a no-op, an ``SoftReadWriteLock(path)`` before acquiring. This follows the invalidation approach used by PyMongo's connection pools. -**Trust boundary.** The class coordinates cooperating processes. Directory ownership, ACLs, and ``0o600`` / ``0o700`` -permissions can exclude another UID. A process with the same effective UID can alter the markers, and incorrect clocks -or cache behavior can trigger expiry while a holder remains active. +**Trust boundary.** The class coordinates cooperating processes at one UID. Mode bits (``0o600`` / ``0o700``) keep other +UIDs out; they do not make a same-UID co-tenant safe, since it owns the markers and can rewrite or delete them directly. +Incorrect clocks or cache behavior can also trigger expiry while a holder remains active. See +:ref:`concepts:The same-UID boundary` for the full contract. *********************************** Use async read / write locks From dc9daaf5c910cd118fc865fdb3e974449f9b4df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 15 Jul 2026 06:50:56 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs:=20refresh=20the=20guid?= =?UTF-8?q?es=20to=20the=20current=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full audit of the docs against the current code found the reader-facing decision aids lagging the strict-lock, lease, and process-identity work: the lock-selection flowchart and comparison table omitted StrictSoftFileLock and SoftFileLease, and several places still described a Windows-only process creation time rather than the start token every platform now records. Extend the flowchart and split the comparison into exclusive and read/write tables that cover all six lock types, add strict-lock and lease selection guidance and a tutorial section, add a SoftFileLease card to the landing grid, note that FileLock and AsyncFileLock are platform aliases in the API reference, and correct every stale liveness description to the cross-platform start token. Part of #641. --- docs/api.rst | 5 ++ docs/changelog/663.doc.rst | 1 + docs/concepts.rst | 132 +++++++++++++++++++++++-------------- docs/how-to.rst | 8 ++- docs/index.rst | 11 +++- docs/tutorials.rst | 41 +++++++++++- 6 files changed, 143 insertions(+), 55 deletions(-) create mode 100644 docs/changelog/663.doc.rst diff --git a/docs/api.rst b/docs/api.rst index f36ada3b..e622acb2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,6 +5,11 @@ This section documents all public classes, exceptions, and attributes. For usage examples and task-oriented guidance, see :doc:`tutorials` and :doc:`how-to`. +:class:`~filelock.FileLock` and :class:`~filelock.AsyncFileLock` are platform aliases: at import time they resolve to +:class:`~filelock.UnixFileLock` or :class:`~filelock.WindowsFileLock` (and their async peers), or to the soft backends on +a build without ``fcntl``. The shared ``acquire`` / ``release`` / ``timeout`` interface they expose lives on +:class:`~filelock.BaseFileLock` and :class:`~filelock.BaseAsyncFileLock` below. + .. automodule:: filelock :members: :show-inheritance: diff --git a/docs/changelog/663.doc.rst b/docs/changelog/663.doc.rst new file mode 100644 index 00000000..f4da3b86 --- /dev/null +++ b/docs/changelog/663.doc.rst @@ -0,0 +1 @@ +Refresh the documentation to the current code: a new trust-boundaries and ownership-scope section (same-UID boundary, advisory native locks, strict claims and lease fencing, a filesystem support matrix, migration off timed stale breaking), the lock-selection flowchart and comparison tables extended with ``StrictSoftFileLock`` and ``SoftFileLease``, and the cross-platform process start token replacing the Windows-only description throughout. diff --git a/docs/concepts.rst b/docs/concepts.rst index 3f8e0e9d..5ff5897a 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -103,17 +103,19 @@ that follows the same locking convention; they do not prevent unrelated access t **Soft locks / PID locks** (SoftFileLock, the fallback on systems without fcntl) -A shared marker indicates that a resource is in use. The marker contains the PID and hostname of the holder. A process -attempts acquisition with ``O_CREAT | O_EXCL`` and releases by deleting the pathname. The filesystem must provide -coherent exclusive creation and directory updates to every participant. +A shared marker indicates that a resource is in use. The marker contains the PID, hostname, and a process start token of +the holder. A process attempts acquisition with ``O_CREAT | O_EXCL`` and releases by deleting the pathname. The +filesystem must provide coherent exclusive creation and directory updates to every participant. This is the same concept as a traditional **PID lock file** (as used by Unix daemons and the deprecated `lockfile `_ library's ``PIDLockFile``). The PID stored in the file identifies the lock holder via the :attr:`~filelock.SoftFileLock.pid` property and lets a waiter detect stale locks when the holding process has died. -Same-host contenders can check whether the recorded PID exists. Windows also records process creation time to -distinguish a reused PID. Other platforms can mistake a reused PID for the original holder and remain blocked. +Same-host contenders reclaim a marker only when the recorded owner is provably gone. Every platform records a process +start token alongside the PID, so a live PID whose start token differs from the record is a recycled PID, not the +original holder. A live PID whose token still matches, a token that cannot be read, and a marker from another host all +read as held. **Strict soft locks** (:class:`StrictSoftFileLock `) use immutable owner claims instead of replacing one shared marker. Each contender writes a complete private record, publishes a unique intent through a hard @@ -211,6 +213,17 @@ when ``fcntl`` is unavailable. - The filesystem lacks a usable native lock, and operators have verified exclusive creation and cache behavior. - Cooperating processes can tolerate shared-marker recovery and its overlap risks. +**Use StrictSoftFileLock** when: + +- You need mutual exclusion a shared marker cannot promise, without a native lock. +- Operator recovery after a crash is acceptable: it never breaks a claim by age, and a crashed holder's claim is cleared + with :meth:`force_break `. + +**Use SoftFileLease** when: + +- Overlap is acceptable and you want a claim that *expires*, so a peer can make progress if a holder wedges. +- You fence the protected resource, because a lease token names a claim but does not fence one. + **Use ReadWriteLock** when: - Your workload is read-heavy with occasional writes. @@ -238,8 +251,11 @@ Lock selection flowchart: questionAsync -->|No| rw["Use ReadWriteLock"] question1 -->|No| question2{"Need network
filesystem support?"} question2 -->|Yes| questionSoftFs{"Verified exclusive create
and cache behavior?"} - questionSoftFs -->|Yes| soft["Use SoftFileLock"] questionSoftFs -->|No| service + questionSoftFs -->|Yes| questionContract{"Which soft-lock
contract?"} + questionContract -->|"Strict, never overlap"| strict["Use StrictSoftFileLock"] + questionContract -->|"Overlap OK, fence resource"| lease["Use SoftFileLease"] + questionContract -->|"Cooperative best-effort"| soft["Use SoftFileLock"] question2 -->|No| question3{"Need platform
specific control?"} question3 -->|Yes| platform["Use UnixFileLock
or WindowsFileLock"] question3 -->|No| default["Use FileLock
(recommended)"] @@ -248,11 +264,11 @@ Lock selection flowchart: classDef alternative fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#78350f classDef special fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a5f class default recommended - class soft,platform,srw alternative + class soft,strict,lease,platform,srw alternative class rw,arw special -Lock types compared -=================== +Exclusive locks compared +======================== .. list-table:: :header-rows: 1 @@ -260,61 +276,77 @@ Lock types compared - - Feature - FileLock - SoftFileLock - - ReadWriteLock - - SoftReadWriteLock - - - Exclusive/shared - - Exclusive only - - Exclusive only - - Both (separate context managers) - - Both (separate context managers) + - StrictSoftFileLock + - SoftFileLease + - - Exclusion contract + - Enforced among lockers (advisory) + - Cooperative shared marker + - Cooperative, never overlaps + - Expiring, overlap permitted - - Platform enforcement - OS-level (Windows/Unix) - No (file-based) - - File-based (SQLite) - - No (file-based, ``O_CREAT|O_EXCL|O_NOFOLLOW``) - - - Network filesystem - - Not reliable - - Works (if you accept the limitations) - - Not reliable - - Works, including cross-host on multi-node clusters - - - Stale lock detection + - No (owner claims) + - No (marker + heartbeat) + - - Stale recovery - N/A (OS-enforced) - - Yes (same-host, all platforms) - - N/A - - Yes, TTL-based heartbeat (cross-host) - - - PID inspection - - No - - Yes (``pid``, ``is_lock_held_by_us``) + - Reclaims a provably-gone owner + - Operator ``force_break`` only (fail-closed) + - Expiry + heartbeat; ``on_compromise`` + - - Owner inspection - No - - No (content is not a public API) - - - Lifetime expiration - - No (ignored with a warning) - - Yes - - No - - Yes (``heartbeat_interval`` / ``stale_threshold``) + - ``pid``, ``is_lock_held_by_us`` + - ``claims``, ``force_break`` + - ``owner``, ``token``, ``compromise`` + - - Network filesystem + - Not reliable + - If verified + - If verified (atomic hard links) + - If verified - - Cancel acquisition - Yes (``cancel_check``) - Yes (``cancel_check``) - - No - - No - - - Force release - - Yes (``force=True``) - - Yes (``force=True``) - - Yes (``force=True``) - - Yes (``force=True``) + - Yes (``cancel_check``) + - Yes (``cancel_check``) - - Async support - AsyncFileLock - AsyncSoftFileLock + - AsyncStrictSoftFileLock + - AsyncSoftFileLease + - - Overhead + - Low + - High + - High (claim directory) + - High (heartbeat thread) + +Read/write locks compared +========================= + +.. list-table:: + :header-rows: 1 + + - - Feature + - ReadWriteLock + - SoftReadWriteLock + - - Readers / writers + - Multiple readers, one writer + - Multiple readers, one writer + - - Backing + - Local SQLite + - Marker tree (``O_CREAT|O_EXCL|O_NOFOLLOW``) + heartbeat + - - Network filesystem + - Not reliable (local only) + - Works, including cross-host on multi-node clusters + - - Stale recovery + - SQLite transactions + - TTL heartbeat (``heartbeat_interval`` / ``stale_threshold``) + - - Async support - AsyncReadWriteLock - AsyncSoftReadWriteLock - - Singleton default - - No - - No - Yes - Yes - - Overhead - - Low - - High - Medium (SQLite) - Medium (daemon heartbeat thread + dirfd scans) @@ -463,9 +495,9 @@ Task cancellation ================= The async wrappers (:class:`AsyncFileLock ` and the async soft, strict, lease, and read-write -variants) run each blocking step on a worker thread. Cancelling the awaiting task while an acquisition is in flight rolls -the attempt back atomically, so a cancelled ``acquire`` never leaves a half-held lock. A task holding a lock that is -cancelled still owns releasing it; cancellation does not release the lock for you. +variants) run each blocking step on a worker thread. Canceling the awaiting task while an acquisition is in flight rolls +the attempt back atomically, so a canceled ``acquire`` never leaves a half-held lock. A task holding a lock that is +canceled still owns releasing it; cancellation does not release the lock for you. Strict claims, leases, and fencing ================================== diff --git a/docs/how-to.rst b/docs/how-to.rst index 2d593ae8..bf77875a 100644 --- a/docs/how-to.rst +++ b/docs/how-to.rst @@ -472,8 +472,10 @@ This happens automatically. You don't need to do anything special: Cross-host records remain because their PID cannot be interpreted locally. On platforms without process-start identity, a reused PID can also keep a dead owner's marker in place. -On Windows, the marker also stores process creation time to guard against PID recycling. Malformed records follow a -different rule: a waiter may evict them after two seconds. That recovery path is not fail closed. +Every platform stores a process start token in the marker to guard against PID recycling: Linux uses the +``/proc//stat`` start time folded with the boot id, macOS reads it through ``sysctl``, and Windows uses the +``GetProcessTimes`` creation time. Malformed records follow a different rule: a waiter may evict them after two seconds. +That recovery path is not fail closed. ******************************** Use fail-closed soft locks @@ -626,7 +628,7 @@ library: :class:`StrictSoftFileLock ` never reclaims a marker. A malformed record, an owner on another host, a dead PID and an old marker all read as held, so acquisition waits rather than overlap a holder that may still be alive. A crashed holder leaves a marker no contender removes; clear it with -:meth:`force_break `, which voids mutual exclusion for whoever is still +:meth:`force_break `, which voids mutual exclusion for whoever is still running. :class:`SoftFileLease ` trades exclusion for progress. The holder refreshes its claim, and a diff --git a/docs/index.rst b/docs/index.rst index 71456b28..181aa5e0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -78,7 +78,7 @@ Choose the right lock for your use case: - ✓ No native locking API required - ✓ Same-host PID inspection - - ✓ Optional age-based expiry, with overlap risk + - ✓ Reclaims a provably-gone owner via process start identity .. grid-item-card:: **StrictSoftFileLock** @@ -89,6 +89,15 @@ Choose the right lock for your use case: - ✓ Removes claims by unique name - ✓ Manual recovery with owner metadata + .. grid-item-card:: + **SoftFileLease** + + Expiring claim with a heartbeat, for when overlap is acceptable and progress matters more than exclusion. + + - ✓ A peer can take over a wedged holder + - ✓ ``on_compromise`` callback on claim loss + - ✓ Fence the resource: a token names a claim, it does not fence one + .. grid-item-card:: **ReadWriteLock** diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 7d876880..594d1ae7 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -15,7 +15,6 @@ We import what we need and create a lock object: .. code-block:: python - from pathlib import Path from filelock import FileLock lock = FileLock("myapp.lock") @@ -260,9 +259,49 @@ pauses, synchronize participating clocks, and fence protected writes if an expir See :doc:`concepts` for the full explanation of the heartbeat + TTL model. +******************************* + Stronger soft-lock contracts +******************************* + +A plain :class:`SoftFileLock ` is a cooperative marker: it reclaims a marker only once its owner +is provably gone, but a shared marker cannot promise that two processes never overlap. When you need more, two soft locks +state an explicit contract. + +:class:`StrictSoftFileLock ` gives mutual exclusion without a native lock. It never breaks a +claim by age, so a crashed holder's claim is cleared only by an operator: + +.. code-block:: python + + from filelock import StrictSoftFileLock + + lock = StrictSoftFileLock("work.lock") + with lock: + do_exclusive_work() + + # After a crash, an operator inspects and clears the stale claim by name: + stale = StrictSoftFileLock("work.lock") + for claim in stale.claims: + stale.force_break(claim.name) + +:class:`SoftFileLease ` trades exclusion for progress. Its claim expires, so a peer can take over +if a holder wedges; a callback fires when this process loses its claim. A lease token names a claim but does not fence +the protected resource, so fence any resource an expired holder could still write: + +.. code-block:: python + + from filelock import SoftFileLease + + lease = SoftFileLease("work.lock", lease_duration=30, on_compromise=lambda c: stop_work()) + with lease: + do_resumable_work() + +See :ref:`how-to:Choose a soft-lock contract` to pick between them. + ************ Next steps ************ +- Need strict exclusion or an expiring lease? See :ref:`how-to:Use fail-closed soft locks` and + :ref:`how-to:Choose a soft-lock contract`. - Want to handle timeouts, cancellation, or force-release? See :doc:`how-to`. - Curious about how locks work across different platforms? Read :doc:`concepts`.