4.2.0:
- Connection policy change: the
RedisLocksubscription now
lives on a dedicated connection that never retries and never
reconnects (retry=Retry(NoBackoff(), 0, supported_errors=()),
holder name set at the connection level, RESP2 with maintenance
notifications disabled), derived per attempt from the command
connection's pool and inheriting that connection's
health_check_interval(a supplied connection left at the redis-py
default of 0 therefore gets no health-check ping on the subscription
either, so a silently partitioned link is only noticed through the
socket, as the module documentation has always advised setting it).
Previously a killed or dropped holder connection
was silently resurrected by redis-py's retry machinery: the holder
resubscribed without its name, kept believing it held the lock
while the channel had already released it, inflated the subscriber
count, and could never be reaped again, permanently blocking
exclusive acquisition. Holders on flaky networks now lose their
locks loudly instead of keeping them incorrectly, which is the
correctness fix. The command connection keeps its default retry
policy, and waiter-side blips still only cost one acquire attempt.
Exotic setups (Sentinel, cluster, custom pools) can supply the new
subscription_connection_factoryparameter. Note that under
redis-py's defaultsocket_timeoutof five seconds a read
stalled that long now counts as a loss, and that a pre-4.2 holder
on the same channel still resubscribes silently when killed, so the
guarantee covers a channel once every participant runs 4.2+ (#137) - Added loss surfacing to
RedisLock: a new
portalocker.LockLostError(carryingchannel,holder_id
and the causal exception as__cause__), alostproperty, an
ensure_held()check for long critical sections, anon_lost
callback invoked exactly once per loss on the keep-alive thread,
and awithblock exit that raisesLockLostErrorafter
releasing when the body finished cleanly.release()never
raises on account of a loss and leaves the loss observable;
acquire()on a lost instance resets it (#137, #141) - Scoped
RedisLockworker failures to what actually failed: a
waiter whose subscription dies retries within its timeout budget
with no process-wide interrupt, while a held lock's worker death
marks the lock lost, and the escalation now catches
BaseException(aSystemExitorKeyboardInterrupt
landing on the worker no longer dies silently). The
main-thread interrupt is governed by the newinterrupt_on_lost
parameter, defaulting to True in 4.2 with aDeprecationWarning
at loss time when left unset; portalocker 5.0.0 flips the default
to False. Acquisition success is confirmed against the worker's
state under a lock, so a subscription dying between the winning
probe and the bookkeeping costs one attempt instead of producing an
imaginary hold (#141) - Made
RedisLockteardown fork safe: a forked child inheriting a
held lock now only drops its local references onrelease()or
garbage collection, instead of sending UNSUBSCRIBE over the
inherited socket and silently releasing the parent's lock while the
parent's own connection stayed healthy and nothing ever told it. The
per-instance mode lock, which the worker thread takes for every ping
answer, is also reinitialized in forked children alongside the state
lock now, so a fork landing inside a ping snapshot no longer hands
the child a permanently locked lock that would hang its first
release()forever. A child that needs the lock must build its
own instance (#137) - Deprecated
RedisLock.check_or_kill_lock(removal in 5.0.0): its
reap arm kills connections on a caller-chosen timeout without the
protocol discipline that protects live-but-slow holders inside
acquire. The new read-onlyRedisLock.probe()answers "who
is on this channel" without side effects (#137) - Fixed
RedisLocknon-blocking acquisition raisingAlreadyLocked
for the writer that had just won the election. The fail check ran
before the promotion check, so twofail_when_lockedwriters on a
free channel could both fail. The winner now takes the lock when no
shared holder remains, so exactly one of two non-blocking contenders
succeeds. The pre-existing reply-staleness window around the
uncontended fast path reached non-blocking winners too and is closed
for blocking and non-blocking writers alike by the confirm probe
added for #145 (#143) - Fixed an elected
RedisLockwriter being usurped by a later writer
with a lower holder id. Holder records now carry anelectedfield
and pending writers defer to an advertised incumbent instead of
rerunning the election against it. Because a ping reply is a
snapshot that can predate the election it should have reported, the
incumbent also holds its promotion while a lower-id newcomer whose
record still carrieselected: falseis visible, since that
record cannot show whether the peer saw the election, rather than
promoting past it into a possible second exclusive holder. Records
keep protocol version 1, 4.0 and 4.1 holders ignore the field and
keep the old election on mixed channels, so no coordinated upgrade
is needed. Protection is complete once every writer on a channel
runs 4.2 or later, at the cost of one extra probe round whenever a
newcomer's reply raced the incumbent's election (#143) - Closed the
RedisLockreply-staleness window that could yield two
exclusive holders. A ping reply is a snapshot that can predate the
answering writer's own fast-path promotion: that writer counted a
single subscriber moments earlier and promoted without ever probing,
so a contender probing inside the window saw only pending holders,
elected itself and promoted too. Under reader-mixed contention at
short check intervals this fired about once per 60 to 200
acquisitions. Every promoted writer, fast path and election alike,
now verifies its promotion with a confirm probe run while its
exclusive record is already visible on the wire. Two freshly
promoted rivals therefore see each other and resolve
deterministically by holder id: the lower id keeps the lock and the
higher id demotes back to a fresh contender, or raises
AlreadyLockedafter a full release underfail_when_locked. A
lower-id pending peer that may still be deciding makes the confirm
probe again rather than conclude from noise, and a rival that will
never demote itself - a pre-4.2 exclusive holder, or a reader that
joined on a stale view of its own - demotes the confirming writer
regardless of id. A subscriber count of one settles the confirm in
one extra round trip, keeping the uncontended acquire at a few
milliseconds, and the confirm also catches most promotions built on
count-preserving churn (#139) after the fact, since it sees the real
holder set instead of a count (#145) - Fixed the probe-reply drain that stretched every reply-staleness
window in theRedisLockprotocol to around a hundred
milliseconds: after collecting a reply the drain polled for the next
one withtimeout=0, almost always missed a reply that was
milliseconds away, and then slept a full jittered drain interval (57
of 60 three-holder probes took an interval, median 116ms). A missed
non-blocking read is now followed by one short real poll so replies
in flight are collected in the same pass, and a probe whose
subscriber count moves while replies are still outstanding gives up
at once instead of waiting out the whole reply timeout for a holder
that already left. The same three-holder probe now takes a couple
of milliseconds, the reader-mixed contention soak completes about
twenty times as many acquisitions in the same wall time, and every
staleness window the protocol still has shrinks by two orders of
magnitude (#145) RedisLockwithfail_when_lockednow raises only on a
conclusive probe showing the channel is held. Inconclusive probes
retry withintimeout, which also lets a non-blocking acquire
succeed after reaping a crashed holder instead of failing
spuriously, at the cost of non-blocking latency of up totimeout
on a noisy channel. Passtimeout=0to keep the strict
single-attempt behaviour (#143)- Tightened
RedisLock.__exit__on two edges: thelostflag is
read after the release now (the state is sticky through
release, so a revocation landing in the instant the block exits
is raised instead of slipping out silently), and a release error can
no longer replace an exception already leaving thewithbody -
it is chained onto the body's exception as its__context__with
a note attached, the same disciplineLock.__exit__has carried
since 4.2.0 - Allowed an
on_lostcallback to callrelease()on the lock it
is told about: the callback runs on the keep-alive worker thread and
the teardown joined that same thread, so the obvious reaction to a
loss raisedRuntimeError: cannot join current threadafter most
of the teardown had already run. The join is skipped on the worker
thread now; the thread exits on its own right after the callback
returns and the instance ends up fully torn down and reusable - Fixed a failed
RedisLock.acquirestranding a live subscription
when the command connection failed after the subscribe but before
the decision (aPUBSUB NUMSUBtimeout, for example): the error
propagated while the instance stayed subscribed with its worker
alive, so its pending record blocked every other writer on the
channel and the instance itself refused the nextacquireas
already active, until someone calledreleaseon it by hand. A
probe failure now gets the same treatment as a subscribe failure:
connection blips burn one attempt and are retried within the
timeout budget, everything else - interrupts included - releases
everything first and then propagates, leaving the channel free and
the instance reusable - Fixed
RedisLockhanging forever inacquireorrelease
when the keep-alive worker was told to stop before its thread had
started running: redis-py'sPubSubWorkerThread.runsets its
running flag from inside the thread andstop()clears that same
flag, so a stop issued in the window betweenstart()and the
thread's first instruction was overwritten and the read loop then
ran forever while the teardown sat injoin(). The lock reaches
that window whenever it tears a fresh subscription down immediately
(a refused confirm, a lost election,fail_when_locked) and the
new worker thread has not been scheduled yet. The worker now records
stop requests in its own event, which the read loop consults before
every read, so a stop can never be lost - Added an opt-in end-to-end self-check to
RedisLock
(self_check_interval, defaultNonemeaning off): every
interval a held lock publishes a liveness ping to its own channel
and requires its own reply back through the response-channel
machinery withinmin(self_check_interval, unavailable_timeout)
seconds, run on the keep-alive worker's cadence with the held
subscription still serviced throughout. This closes the half-open
link hole that socket-level detection cannot see (a partition with
no TCP reset delivers nothing and errors never). A failed check is
classified as a connection loss -RedisLockSelfCheckError
becomes the__cause__of theLockLostError- and surfaces
through exactly the channels a socket-detected loss uses. Costs
two round trips plus one channel-wide reply round per interval per
holder, and a broken command path fails the check too, which is
why it stays opt-in (#146) - Added opt-in fencing tokens to
RedisLock(fencing=True):
every exclusive grant runsINCRon the never-expiring
<channel>-fencekey right after the confirm probe and exposes
the result asfence_token, so resources that can check fences
reject writes from a holder revoked inside the detection window -
the one window no lock can close by itself. The token isNone
while no fenced grant stands, survives loss andreleasefor
forensic use until the nextacquire, and is never drawn for
shared holders. A failedINCRfails the acquire (transient
blips burn one attempt, a wrong-typed key raises), so a
fencing-enabled lock is never held without a token. Only
fencing-enabled 4.2+ writers bump the counter, so mixed channels
narrow the guarantee to the writers that opted in (#146) - Made the 100% coverage gate measure what it claims: the entire
Windows locking implementation, theLockBasebase class (the
timeout generator, the context manager protocol and the retry
plumbing), the version discovery fallbacks, the redis-less
RedisLockstub and the pubsub failure escalation were all
excluded wholesale via blanketpragma: no covercomments, so the
gate passed without those regions ever being measured. Platform
splits now use the per-OS conditional coverage rules (measured on
the platform they run on, excluded only where they cannot run), the
policy-wide exemptions for guard raises (raise AssertionError,
raise NotImplementedError,except ImportError:and friends)
are gone from the coverage configuration, and the newly measured
code is exercised by tests on every platform. The fork-safety hook
registration was also tagged for the wrong platform: it was excluded
on POSIX, where it runs, and measured on Windows, where it cannot
run. Two unreachable defensiveimport msvcrtguards inside the
os.name == 'nt'branch were removed outright:msvcrtships
with every Windows Python build - Fixed two concurrent
release()calls on oneLockunlocking a
stranger's lock: both callers passed the held-handle guard, and the
loser then ran the OS unlock on a closed and possibly reused file
descriptor, silently dropping whichever lock that descriptor number
belonged to by then. Every lock now claims its state atomically under
a per-instance reentrant state lock, so exactly one caller tears the
lock down and concurrent or reentrant callers no-op - Fixed a
release()reentering from a signal handler (the standard
SIGTERM graceful-shutdown idiom) between the ownership guard and the
unlink ofTemporaryFileLock.releaseandPidFileLock.release
unlinking the lock files of whoever acquired the lock in between. The
handle is claimed before the first OS call, so the reentrant release
finds nothing to do and the successor's files survive - Fixed a
KeyboardInterruptescapingfh.close()during release
leaving the handle stored after the file was unlinked, which let the
next release pass the guard and unlink the successor's file. The
stored handle is cleared before the close is attempted - Fixed two threads sharing one
BoundedSemaphoreinstance both
taking a slot: the second publication overwrote the first, one exit
then released the other thread's slot and the orphaned slot stayed
locked until garbage collection. The publication re-checks the
already-taken guard in one atomic step now, so the losing thread
gives its extra slot back and gets aLockExceptioninstead of
leaking it. The slot sweep itself deliberately runs outside the
instance state lock, so anos.forkin another thread cannot
capture the state lock held across the sweep's OS calls - Fixed a
KeyboardInterruptorSystemExitlanding between a
BoundedSemaphoreslot lock succeeding and its publication
stranding the OS lock on a traceback-pinned local, where refcount
collection never frees it and the slot stays blocked for every
contender. The sweep now rolls the slot back (un-publishing it
first when the interrupt landed after publication, guarded by
identity so another thread's slot stays untouched) and re-raises,
the same treatmentPidFileLockgot for its sidecar - Fixed a child forked while any thread held an instance state lock
deadlocking forever on its firstrelease(),acquire()or
interpreter-exit cleanup: the child inherited the lock in its locked
state, owned by a thread that does not exist there. Every live lock
instance's state lock is reinitialized in the child via
os.register_at_fork, the way the standard library'slogging
module protects its handler locks - Fixed the after-fork state-lock cleanup crashing with
AttributeErrorwhen invoked directly on Windows: CPython only
compiles_at_fork_reinitinto its lock types on builds with
fork, so the hook now skips state locks without the method and
is a documented no-op onnt, where nothing ever forks - Fixed
PidFileLock.read_pidraisingUnicodeDecodeErrorfor a
PID file whose bytes the locale encoding cannot decode (arbitrary
junk on a cp1252 Windows, invalid UTF-8 on POSIX). The file is read
as bytes and validated as ASCII digits now, so undecodable content
reads asNonelike any other unreadable value, on every platform
alike - Fixed
PidFileLock.read_pidraisingValueErrorfor a PID file
holding more than 4300 digits, CPython's integer-conversion limit.
No real PID needs more than 20 digits, so longer digit runs now read
asNonelike any other junk instead of leaking an exception the
contract does not allow - Fixed
RLock.releasezeroing the count and claiming the handle in
two separate state-lock scopes: an acquire racing into the gap saw
the count at zero with the handle still published, took the fast
path, and was handed the very filehandle the release then closed.
The count transition and the claim are one atomic scope now - Fixed a failing
PidFileLock.acquirecontender's rollback wiping
the instance state a winning thread had published concurrently: the
winner's__exit__then no-oped and garbage collection of its
orphaned sidecar freed the OS lock in the middle of the guarded
block. The rollback only clears the state when its own failed
sidecar is the published one - Fixed
PidFileLock.acquirecrashing withAssertionError(or
returningNoneunderpython -O) when a signal handler's
release()landed between publishing the lock and returning: the
return value is the locally bound sidecar handle now, never re-read
from the shared state - Documented that two threads racing
Lock.acquireon one instance
is unsupported: with a per-process locker (POSIXlockf) both
lock calls succeed, the second publication overwrites the first, and
the overwritten descriptor's eventual close releases the process's
record locks on the file. That is inherent to POSIX record locks
(any descriptor's close drops them), so no publication strategy can
paper over it; use one instance per thread - Hardened the verified acquire of
TemporaryFileLockand the
PidFileLocksidecar against reentrant releases: a handle a
signal handler claimed and closed mid-acquire is retried within the
remaining timeout budget instead of failing the inode verification,
and a closed or OS-level-dead handle found by the held-lock
re-acquire reports the documented compromised-lockLockException
instead of leaking a rawValueErrorfromfileno()or an
EBADFOSErrorfromfstat - Behaviour change:
Lockresolves its path withos.path.abspath
at construction, so thefilenameattribute now holds an absolute
path. A relative path used to be resolved on every later OS call,
and anos.chdirbetween acquire and release (the daemonize idiom
doeschdir('/')) made release and the interpreter-exit cleanup
unlink another process's equally-named lock files at the new working
directory while leaving the lock's own files behind - Fixed a
KeyboardInterruptduringLock.acquireleaking the
opened descriptor for the traceback's lifetime when it landed in the
retry sleep, and leaving the OS lock held by an untracked descriptor
(withreleasea silent no-op) when it landed between the
successful lock and the publication of the handle. Every failed exit
fromacquire, interrupts included, now unlocks and closes the
descriptor first;PidFileLock.acquirerolls its sidecar back the
same way - Fixed the interpreter-exit cleanup skipping a
TemporaryFileLock
orPidFileLockconstructed before a fork and acquired inside the
child: the owning pid was recorded at construction only, so the
child's exit left its lock file, and forPidFileLocka stale PID
payload, behind. Ownership is re-recorded on every fresh acquire.
Also documented that awith lock:block entered before a fork
runs__exit__in both processes, so the daemonize pattern must
fork outside the block or leave the child viaos._exit - Fixed
RLocklosing acquire counts when two threads nested
acquires on one instance: the bare read-modify-write let one
increment overwrite the other, and the later releases closed the
file while a hold was still outstanding. The counter transitions run
under the instance state lock now, which also closes the equivalent
lost update on free-threaded (no-GIL) builds - Fixed
PidFileLock.__exit__replacing thewithbody's own
exception with a release error and ignoring
raise_on_release_errorin both directions: the POSIX release
leaked unlink errors with the flag unset and the Windows release
swallowed them with it set. The exit path routes through
Lock.__exit__now (release failures are chained onto the body's
exception instead of masking it) andPidFileLock.releasefollows
the flag: unlink failures are logged by default and raised in strict
mode - Fixed
TemporaryFileLockandPidFileLockrejecting the
raise_on_release_errorkeyword their documentation described:
both constructors accept and forward it now - Fixed the Windows unlink retry of
TemporaryFileLock.release
letting every non-PermissionErrorfailure escape regardless of
raise_on_release_error, unlike the POSIX path, and sleeping once
more after its final failed attempt. Non-retryable errors follow the
flag contract now and the trailing sleep is gone - Behaviour change:
PidFileLock.acquireno longer normalizes plain
LockExceptionfailures from the sidecar toAlreadyLocked.
AlreadyLockedmeans contention and nothing else; a terminal
backend failure (ENOLCK, an unsupported filesystem) propagates
as itself instead of telling callers to retry a failure retrying
cannot fix - Fixed lock exceptions being unpicklable when they carried an open file
object onfh, which made every contention raised inside a
multiprocessingworker crash the result pipe with
MaybeEncodingError: ... cannot pickle 'TextIOWrapper'instead of
deliveringAlreadyLockedto the parent. Pickling now drops the
handle (an integer descriptor is kept) and the newfh_name
attribute preserves the file's name as a plain string, recursively
for wrapped lock exceptions.copy.deepcopydrops the handle the
same way, whilecopy.copykeepsfhshared with the original
via an explicit__copy__, since a shallow copy never leaves the
process where the handle is valid. A handle whosenamelookup
raises (a detachedio.TextIOWrapper) no longer breaks exception
construction - Fixed the
AlreadyLockedraised byLock.acquirewith
fail_when_locked=Truehavingstrerror=Nonewith the OS message
buried inside a wrapped inner exception. The wrap now propagates the
locker's own arguments, so.strerroris populated at theLock
level as the migration guide promises.args[0]is therefore now
the originalOSError(POSIX) or error code (Windows) instead of
the inner lock exception, which stays reachable as__cause__.
The wrap also forwardsfhandholder_pidfrom the inner
exception, sofh_nameand the holder PID survive a pickle across
a multiprocessing boundary, where__cause__does not - Behaviour change: a failing POSIX module-level
unlocknow raises
portalocker.LockExceptioninstead of leaking the rawOSError,
matching what the Windows unlock has always done. TheOSError
(and itserrno) stays reachable throughargs[0]and
__cause__. The NFS-specificEOFErroris wrapped as well,
matching whatlockalready did - Deprecated
exceptions.FileToLarge: no version of this package has
ever raised it, so code catching it catches nothing. The class stays
for backwards compatibility and now emits aDeprecationWarningon
instantiation - Corrected the exception documentation: the docs claimed every raise
passes theLOCK_FAILEDcode asargs[0], but the POSIX lockers
put the originatingOSErrorthere. Both real shapes are now
described, the doctests build the honest POSIX shape, the module-level
POSIXlock/unlockdocstrings note that a raw
(lock, unlock)callable tuple owns its own error translation, and
the README'r+'example now creates its file first - Fixed
RedisLockstale-holder cleanup killing healthy holders of
other channels. The cleanup prefix-matchedCLIENT LISTnames, so a
probe on channelamatched the holders of a channel named
a-lock-b(whose connections are nameda-lock-b-lock-<id>) and
killed them, along with any unrelated client whose name happened to
start witha-lock-. Client names are now matched exactly against
the<channel>-lock-<32 character hex holder id>shape, and the
bare legacy<channel>-lockname is still reaped as before (#142) - Fixed
RedisLocksleeping out a jitteredcheck_intervalbefore its
first acquisition attempt, which cost an uncontended acquire roughly
250ms for nothing. The retry generator now yields immediately and only
sleeps between attempts. The probe poll loops stay paced because their
get_message(timeout=...)calls block on their own (#144) - Behaviour change: acquiring a
RedisLockinstance that is already
holding a lock now raisesportalocker.LockExceptioninstead of
AssertionError. The assert was the only re-acquire guard and
python -Ostrips asserts, which silently orphaned the worker thread
and left a phantom holder on the channel (#144) - Documented a known mixed-version limitation of
RedisLock: holders on
portalocker 3.2.0 and older share one connection name, so one live plus
one crashed legacy holder on a channel cannot be told apart and block
waiters until the crashed holder's TCP connection dies on its own (#144) - Made
RedisLockteardown exception safe.releasenow runs every
teardown step even when an earlier one fails, clearsthread,
pubsuband a self-created connection regardless, and re-raises only
the first error, so a failingUNSUBSCRIBE(Redis unreachable while
the lock was held) no longer leaves a stalepubsubbehind that made
every lateracquireon the instance fail its already-active guard
(#140) - Fixed the
RedisLockrollback for a worker thread that fails to
start: the never-started thread is no longer joined, so the original
error propagates out ofacquireinstead ofRuntimeError: cannot join thread before it is started, and the subscribed pubsub no longer
leaks. A rollback that fails as well is logged instead of replacing the
original error (#140) RedisLock.releaseno longer checks a fresh connection out of the
pool purely to sendUNSUBSCRIBEfor a subscription the worker
thread already discarded when it stopped. The unsubscribe now only runs
when the pubsub still owns a connection, which is also what made
RedisLock.__del__fail loudly at interpreter shutdown (#140)RedisLock.__del__is now best effort and suppresses all errors
instead of surfacing them as interpreter-level "Exception ignored in"
messages during garbage collection or shutdown (#140)- Fixed a contended
RedisLockwith a self-created connection (no
connection=argument) killing its own worker thread and delivering a
KeyboardInterruptto the main thread. The release between retries
closed and cleared the connection whileacquirekept resubscribing on
a stale reference, sochannel_handlerfailed its connection assert
and the failure was escalated to the main thread. Retries now drop only
the subscription and keep the connection; the connection is closed on
final release or whenacquiregives up (#136) - Fixed a
RedisLockprobe reading at most one reply per polling
interval, which capped a probe at roughly ten replies inside the default
one secondunavailable_timeoutno matter how fast the holders
answered. On a channel with more holders than that every probe came up
short and killed healthy holders whose replies were sitting unread in
the prober's own buffer. The reply loop now drains all buffered replies
within each interval, so the interval paces the polling instead of
capping the throughput (#138) - Closed three
RedisLock.acquireraces that could let two writers both
conclude they held the lock exclusively. Subscribing now waits for the
server's subscribe confirmation instead of sleeping 10ms, so a subscriber
count can no longer run before the server registered the subscription.
Mode promotions and ping answers now serialize on athreading.Lock,
so a probe can no longer read a torn or stalependingfrom a writer
mid-promotion. A probe now re-checks the subscriber count immediately
before pinging as well as after collecting replies. Count-preserving
churn between those two checks remains undetectable becausePUBSUB NUMSUBreports counts rather than identities, and is tolerated because
losing waiters retry. Also documented thatRedisLockrequires a
single standalone Redis endpoint, sincePUBSUB NUMSUBis node-local
in cluster and replica setups (#139) - Fixed a 4.0.0 regression where garbage collection of a lock object tore
down a held lock:LockBase.__del__released the OS lock, closed the
filehandle and, forTemporaryFileLockandPidFileLock, unlinked
the lock file the moment the wrapper was collected. The throwaway idiom
fh = Lock(path).acquire()therefore lost mutual exclusion instantly,
since only the filehandle stays referenced. The finalizer is removed,
restoring the 3.2.0 semantics, andTemporaryFileLockstill cleans
up a lock held at interpreter exit through itsatexithandler.
That handler resolves a weak reference, so the exit cleanup needs the
wrapper to still be referenced. A wrapper discarded mid-run leaves
the lock file behind at exit, with the lock itself released once the
filehandle is closed or collected release()now honoursraise_on_release_error=Falseon the
LockandTemporaryFileLockteardown paths:
TemporaryFileLock.release()suppressed onlyFileNotFoundError
from the unlink, so for example aPermissionErrorfrom a read-only
directory escaped despite the default and could replace an exception
already leaving awithblock. Suppressed release errors are now
logged at warning level instead of disappearing, which also means
that with no logging configured they print to stderr through Python's
last-resort handler. That is intentional visibility for previously
silent failures, not a new bug.Lock.__exit__guarantees the
block's own exception wins whether or notraise_on_release_error
is set, with the release error chained on as its__context__, and
the chain is kept free of the reference cycle that a release error
raised while the body exception was in flight used to create.
PidFileLockoverrides__exit__but routes it through
Lock.__exit__and its release honours the flag as well, both
fixed in this release by thePidFileLock.__exit__bullet above- Fixed
LockBase.__delete__releasing the wrong object: deleting a
lock stored as a class attribute (del owner.attribute) called
release()on the owner instead of the lock, raising
AttributeErrorand leaving the lock held. The lock now releases
itself - Corrected the 4.0.0 changelog entry that claimed
Lock.release()
"continues suppressing unlock and close errors by default": 3.x
propagated those errors, so the suppression was a 4.0.0 behaviour
change. The default stays as documented in 4.0.0, now with the
warning-level logging described above - Behaviour change: the module-level
lock()now validates its flags
on every platform, before any system call. Flags carrying
LockFlags.UNBLOCKraiseRuntimeError(on POSIX they used to
silently release the held lock, since the bit went straight through
tofcntl).SHARED | EXCLUSIVEraisesRuntimeError. A flag
set naming no lock type at all (LockFlags(0)orNON_BLOCKING
alone) raisesRuntimeErroron every platform instead of on POSIX
only - Fixed the
MsvcrtLockerfallback table forLK_*constants
missing frommsvcrt. The old values were shifted against the real
<sys/locking.h>numbers, so a "blocking lock" through the fallback
would have issued an unlock (LK_LOCKfell back to the
LK_UNLCKvalue). The corrected values also now live on the locker
instance instead of beingsetattr'd onto the shared stdlib
msvcrtmodule Win32Locker.locknow wrapsOSError(for example a stale file
descriptor handed tomsvcrt.get_osfhandle) inLockException,
matching the unlock path. Previously the rawOSErrorescaped
lock()Win32Lockernow creates a freshOVERLAPPEDstructure for every
LockFileEx/UnlockFileExcall instead of reusing one cached
instance across calls and threads, which the Win32 API contract
forbidspython -m portalocker combinenow reads and assembles all of its
inputs before opening--output-file. It used to truncate the
output file first, so a non-ASCII byte in an input destroyed a
pre-existing build and died with a raw traceback. Decode failures in
README.rstandLICENSEnow log the same snippet, naming the
offending file, that the source modules already got- Behaviour change: without the optional redis dependency installed,
portalocker.RedisLockis now a stub class whose constructor raises
ImportErrornaming the dependency and the install command
(pip install "portalocker[redis]"). It used to beNone, so
constructing it failed withTypeError: 'NoneType' object is not callable. Code that comparedRedisLock is Noneto detect the
extra should try constructing it and catchImportErrorinstead - Removed the legacy universal-newline (
U) mode strings from the
portalocker.types.Modeliteral. Python 3.11 removed them, and 3.10
only accepted them with a warning, so this narrows the static typing
contract only and changes nothing at runtime - Documented that a blocking
msvcrtlock retries ten times at one
second intervals and then raises, instead of blocking indefinitely
like POSIX, and added the missing 4.0.0 migration note for
LockfLocker: it silently usedflockup to 3.2.0, and the
switch to reallockfchanges same-process contention, makes
closing any descriptor for the file drop the locks, and leaves a 3.x
holder and a 4.x holder unable to exclude each other on Linux - Fixed
Lock.acquireleaking an open, still locked filehandle when
preparing the file failed after the lock was taken, for example mode
won a macOS append-only (chflags uappnd) file, where the
deferred truncate raisesEPERM.self.fhwas never assigned, so
releasewas a no-op while the escaping traceback pinned the handle
alive and the file stayed locked indefinitely. The handle is now
unlocked and closed before the original error escapes - Behaviour change:
Lock.acquirenow retries only contention, which
the locking backend reports asAlreadyLocked. A plain
LockException, such asflockrefusing a FIFO or an NFS/SMB
mount without locking support,ENOLCK, or theEOFErrorsome
NFS setups raise fromfcntl, is permanent: it is now raised
immediately instead of being retried for the whole timeout, because
retrying cannot make a filesystem grow locking support. With
fail_when_locked=Trueit is no longer wrapped in
AlreadyLocked, which claimed somebody held a lock on a filesystem
that cannot lock at all. The same classification reaches
BoundedSemaphore: a plainLockExceptionwhile probing a slot
used to be mistaken for a taken slot and skipped, it now propagates
and aborts the acquire. Custom lockers must raiseAlreadyLocked
for contention to keep being retried, as the bundled lockers already
do - Fixed
LockBase._timeout_generatorsleeping past its deadline by up
to one fullcheck_interval: a contendedLock(timeout=0.5, check_interval=3)gave up after roughly 3 seconds instead of 0.5.
Every sleep is now capped at the time remaining until the deadline.
RedisLockis unaffected, it overrides the generator with its own - Fixed the "timeout has no effect in blocking mode" warning firing
twice for aLockbuilt with an explicit timeout (at construction
and again on everyacquire), pointing at portalocker's own source
instead of the caller, and firing forRLock,
TemporaryFileLockandPidFileLockinstances constructed
without any timeout argument, which failed user suites running with
filterwarnings = error. The subclasses now forwardNoneso the
default timeout no longer counts as caller-provided, and the warning
fires at most once per lock instance, with a stacklevel computed by
walking past portalocker's internal frames so it names the caller's
file from every entry point, subclass constructors included - Behaviour change:
RLock.acquireon an instance whose acquire count
claims the lock is held while no filehandle exists now raises
portalocker.LockExceptioninstead of relying on a bareassert,
whichpython -Ostrips, silently handing the callerNoneas
the filehandle - Fixed positioned writes for
Lockmodes containingwon POSIX.
The deferred-truncationasubstitution leftO_APPENDset, so
the kernel ignored seek positions andfh.write('x'); fh.seek(0); fh.write('y')produced'xy'where the builtinopen(mode='w')
produces'y'. The append flag is now cleared once the truncation
is done. Windows offers no way to drop the flag from an open handle,
so there the append semantics remain and are documented - Behaviour change: fixed
TemporaryFileLock.acquiredestroying a held
lock when a third party unlinked or replaced the lock file (a tmp
cleaner sweeping/tmpis enough). The inode re-check introduced with
the 4.0.0 split-brain fix released and closed the caller's live
filehandle on the mismatch. With the default timeout it then silently
re-acquired a new inode (an unlocked window a competitor could win,
with the caller's original handle left closed), and withtimeout=0
it raisedAlreadyLockedafter having dropped the lock it actually
held. Re-acquiring while holding a still-valid lock file is now an
idempotent no-op returning the held filehandle, and a held lock whose
path was unlinked or replaced externally now raisesLockException
and leaves the held filehandle untouched instead of closing it. A
PidFileLockre-acquire runs the same verification on its sidecar.
On both classesreleasenow also skips the unlink, with a warning
in the log, when the held handle no longer names the path, so a
compromised holder cannot delete the lock file a competitor has since
created. The inode comparison usesos.path.samestat, which checks
the device as well as the inode - Fixed the
TemporaryFileLock.acquireverification retry restarting
the full timeout for every attempt, which compounded the worst-case
wall time to roughlytimeout**2 / check_interval. The retries now
share a single deadline and every retry is only handed the remaining
budget - Fixed
PidFileLock.acquireon an instance that already holds the
lock dropping the held OS lock mid-call: the old sidecarLockwas
overwritten, and the discarded object's teardown released the lock
before the replacement re-acquired it, a window another process could
win. A second acquire on a holding instance is now an idempotent no-op
that touches neither the sidecar lock nor the PID file - Fixed
PidFileLockignoring its instance-leveltimeoutand
check_intervalwhen acquiring withfail_when_locked=False: the
sidecarLockwas built from the per-call arguments only, so a
Noneargument silently selected the five second module default
instead of the instance attribute, making
PidFileLock(path, timeout=0.4).acquire()block for five seconds
whileacquire(timeout=0.4)behaved. The call arguments are now
coalesced with the instance attributes first, per the documented
LockBasecontract - Fixed a contender interrupted while waiting for a
PidFileLock
destroying the live holder's lock on its own exit.acquirestored
the sidecarLockbefore taking it and onlyexcept Exception
cleared it, so aKeyboardInterruptorSystemExit(a SIGTERM
handler callingsys.exitis the usual daemon idiom) left the
instance claiming a lock it never took, and its release, explicit or
via the exit handler, unlinked the PID and sidecar files belonging to
the actual holder, letting the next acquirer create a second holder.
The sidecar reference is now only published after a fully successful
acquire, andreleaseadditionally refuses to unlink anything when
the sidecarLockno longer holds a filehandle. An interrupt
arriving between taking the sidecar lock and publishing it now rolls
the sidecar back as well, where it previously stranded the OS lock on
a local variable that only garbage collection could release, so a
pinned traceback kept every contender blocked - Behaviour change:
PidFileLockused as a context manager now raises
AlreadyLockedon entry when another process holds the lock but its
PID cannot be read (missing, unreadable or invalid PID file). It used
to returnNonein that case, which the documented contract defines
as "this process is the holder", so a chmod'ed or deleted PID file made
callers run their exclusive block next to a live holder PidFileLock.read_pidnow only accepts a plain positive ASCII
decimal and reports anything else as unreadable (None). It used to
parse everythingintaccepts, including-1,0,+7,
1_000and non-ASCII digits, and the obvious consumer feeds the
result toos.kill, where-1signals every process the user owns- Fixed
PidFileLockpublishing the PID by truncating the PID file in
place, which let a concurrent reader observe the previous (possibly
dead) holder's PID or an empty file mid-write. The PID is now written
to a temporary file next to the PID file and moved over it with
os.replace, so readers see either the old complete PID or the new
complete PID - Fixed the Windows
PidFileLock.releasepath unlinking the PID file
after releasing the sidecar lock, which could delete the PID a fast
successor had already published. The PID file, which carries no OS lock
on any platform, is now unlinked before the sidecar release, mirroring
the POSIX ordering TemporaryFileLockandPidFileLocknow annotatefilenameas
types.FilenamelikeLockdoes, so passing apathlib.Path,
which always worked at runtime, no longer fails static type checking- Documentation fix: the 4.1.0 documentation gave
BoundedSemaphore
two contradictingfail_when_lockedcontracts. The constructor
docstring promised that a full semaphore raisesAlreadyLocked
straight away, whileacquiredocumented what the code has
actually done since 3.2.0: the flag is consulted only once the
timeouthas expired. The runtime behaviour is unchanged and the
constructor docstring was the one corrected. A full semaphore retries
for the whole timeout and then raisesAlreadyLocked, or returns
Nonewithfail_when_locked=False. Both the timing and that
Nonereturn diverge from the other lock classes and are now
called out loudly in the documentation - Behaviour change: acquiring a
BoundedSemaphoreor
NamedBoundedSemaphoreinstance that already holds a slot now
raisesportalocker.LockExceptioninstead ofAssertionError.
The assert was the only re-acquire guard andpython -Ostrips
asserts, so a second acquire silently consumed a second slot,
overwrote the reference to the first and starved competitors of a
slot nobody could release anymore - Fixed the
BoundedSemaphoredefault-nameDeprecationWarning
being attributed to portalocker's own source (stacklevel=1). It
now points at the constructing caller, so it names the code to fix
and deduplicates per call site instead of once globally - Fixed
TemporaryFileLockandPidFileLockregistering one
atexitcallback per constructed instance and never unregistering
it, which grew without bound in long-running processes churning
through short-lived locks. A single module level hook registered once
at import now releases whichever locks are still held at interpreter
exit - The interpreter-exit cleanup for
TemporaryFileLockand
PidFileLockno longer releases locks a forked child inherited
from its parent: the exit hook now only releases locks constructed by
the exiting process itself. The child inherits the live lock objects,
and its normal exit unlinked the parent's lock files while the parent
still believed it held them, breaking the classic acquire-then-fork
daemonize sequence. Together with 4.2.0's removal of lock teardown at
garbage collection time this closes that fork hole for locks acquired
before forking. A lock constructed in the parent but acquired inside
a forked child belongs to the child: ownership is re-recorded on
every fresh acquire (fixed in this release as well), so that child's
exit cleans its lock up - Behaviour change:
portalocker.open_atomicnow raises
FileExistsErrorwhen the destination already exists on entry,
matching its documentation and the publication-time race. It raised
AssertionErrorbefore - Fixed a 4.0.0 regression in
portalocker.open_atomic: publication
uses a hard link on POSIX, which hard-failed on filesystems without
hard link support (exFAT and some SMB, NFS and FUSE mounts) where
3.2.0's rename worked, and the cleanup then deleted the freshly
written payload as well. Publication now falls back to an existence
check plus rename on those filesystems. The fallback still publishes
content atomically but cannot reliably refuse concurrent publishers,
so the strong no-replace guarantee continues to require hard link
support, as the documentation now states - Behaviour change: when
open_atomicfails to publish, for any
reason, the temporary file is now kept and the raised exception names
its path, where the payload was previously deleted without a trace.
An exception from the caller's body, on the other hand, now removes
the temporary file that used to be leaked, and a body that closes the
handle itself no longer breaks publication because the payload is
synchronized through a fresh descriptor - Fixed
open_atomicpublishing destinations with the private
0o600permissions of its temporary file. The temporary file is
now created with mode0o666so the kernel applies the process
umask at creation, and the destination carries the permissions a
plainopenwould have produced, without portalocker ever touching
the process-wide umask - Documented two
BoundedSemaphoreoperational hazards: a slot file
deleted externally mid-hold silently admits an extra holder, and the
default directory is the tmp-cleaner-patrolled system temporary
directory, so long-running semaphores need a private directory exempt
from cleanup. Also documented thatopen_atomicdoes not fsync the
directory entry, so the published name itself is not guaranteed
durable across power loss