Skip to content

AdvisoryLock: never strand a handle acquired after DisposeAsync drained (#396) - #397

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/396-advisory-lock-post-drain-strand
Jul 30, 2026
Merged

AdvisoryLock: never strand a handle acquired after DisposeAsync drained (#396)#397
jeremydmiller merged 1 commit into
masterfrom
fix/396-advisory-lock-post-drain-strand

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #396.

TryAttainLockAsync checked _disposed only at method entry and then stored the winning handle unconditionally:

if (_disposed) return false;                     // entry check only (weasel#349)
var handle = await locker.TryAcquireAsync(...);  // <-- DisposeAsync can drain _handles here
if (handle is not null) { _handles[lockId] = handle; return true; }

An acquire in flight when DisposeAsync drains puts its handle into the drained dictionary, where nothing will ever dispose it — a granted advisory lock held until the process exits. With TransactionalLockEnabled = true (Marten's default Events.UseAdvisoryLockTransaction) that is a permanently idle in transaction backend on pg_try_advisory_xact_lock, which Marten's high-water gap detection reads as a live pre-gap reserver and never advances past — JasperFx/marten#5090. The connection is leased by the handle, so NpgsqlConnection.ClearAllPools() cannot reclaim it, and disposing the NpgsqlDataSource only does when the consumer owns it.

Measured before the fix, sweeping the dispose timing across the whole acquire, 120 iterations each:

probe result
DisposeAsync racing an in-flight acquire 120/120 stranded
cancelling an in-flight acquire 0/120 — Medallion cleans up correctly

So the shutdown token was never the leak path; the unsynchronized handle store was.

Change

The store now happens under the same lock DisposeAsync latches and drains under, which makes the two orderings exhaustive: either the handle lands before the drain and the drain disposes it, or it observes the disposal and disposes itself (reporting not-attained). Two things fall out of that:

  • A displaced handle — one whose lock was lost in monitored mode and re-attained — is now disposed rather than silently overwritten in place.
  • Every other _handles access moves under the same lock. The dictionary was being mutated concurrently by the caller's leadership poll, ReleaseLockAsync, and DisposeAsync with no synchronization at all, and read unsynchronized by HasLock.

Adopts JasperFx 2.36.3 (from 2.24.1 / JasperFx.Events 2.0.0), which carries the companion fix for the coordinator loop that opens this window in the first place: JasperFx/jasperfx#592, where ProjectionCoordinatorBase.StartAsync disposed the old CancellationTokenSource without cancelling it and orphaned a live leadership loop.

Tests

  • an_acquire_that_completes_after_disposal_does_not_strand_its_lock — races an acquire against DisposeAsync 20 times, then proves a completely separate AdvisoryLock can still take the key. Verified RED before the fix.
  • disposal_releases_a_lock_attained_before_it — the ordinary drain path, and HasLock after disposal.

Local: Weasel.Postgresql.Tests 785 passed / 0 failed / 3 skipped on net10.0.

🤖 Generated with Claude Code

TryAttainLockAsync checked _disposed only at method entry and then stored the
winning handle unconditionally. An acquire in flight when DisposeAsync drained
_handles put its handle into the drained dictionary, where nothing would ever
dispose it: a granted advisory lock held for the life of the process. With
transaction-scoped locks (Marten's default) that is a permanently
'idle in transaction' backend on pg_try_advisory_xact_lock, which Marten's
high-water gap detection reads as a live pre-gap reserver and never advances
past — JasperFx/marten#5090. The connection is leased by the handle, so
ClearAllPools() cannot reclaim it either.

The store now happens under the same lock DisposeAsync latches and drains under,
which makes the two orderings exhaustive: the handle lands before the drain and
is disposed by it, or it observes the disposal and disposes itself. A displaced
handle (a lock lost in monitored mode and re-attained) is disposed rather than
overwritten in place, and every other _handles access — HasLock,
ReleaseLockAsync — moves under the same lock, since the dictionary was being
mutated concurrently by the caller's poll loop and by disposal with no
synchronization at all.

Also adopts JasperFx 2.36.3, which carries the companion fix for the coordinator
loop that opens this window (jasperfx#592).

Regression test races an acquire against DisposeAsync 20 times and then proves a
separate lock can still take the key. Verified RED before the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit cebd7c1 into master Jul 30, 2026
17 of 20 checks passed
@jeremydmiller
jeremydmiller deleted the fix/396-advisory-lock-post-drain-strand branch July 30, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AdvisoryLock strands a handle acquired after DisposeAsync — permanent 'idle in transaction' advisory-xact-lock session

1 participant