Use a Mutex on Windows so an abandoned package journal lock can be recovered - #2157
Conversation
On Windows, SystemSemaphoreManager.Acquire backs the package journal lock with a named Semaphore. A Semaphore has no notion of ownership, so when the holding Calamari process goes away without running the Releaser (killed mid-ApplyRetention, Tentacle restart, OOM) the count is never restored. The AbandonedMutexException handler in AcquireSemaphore can therefore never fire, and the unbounded WaitOne() waits forever - any concurrent deployment calling RegisterPackageUse or release-package-lock hangs with nothing but a single verbose line to show for it. This test asserts the behaviour we want - that Acquire recovers from an abandoned holder - so it fails until that is fixed. It abandons the holder through Acquire() itself rather than constructing a named Semaphore, which keeps it independent of the primitive and the name the implementation uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the named Semaphore in SystemSemaphoreManager with a named Mutex on Windows, which is what the *nix path already used. A Mutex has an owner, so when the holding thread or process terminates without releasing it the kernel signals the mutex as abandoned and hands ownership to the next waiter. The existing AbandonedMutexException handler - previously unreachable on Windows, because a Semaphore can never be abandoned - now does the job it was written for, and Acquire() recovers instead of blocking on its unbounded WaitOne(). This also corrects a naming inconsistency: the Windows path created the semaphore under the unprefixed name while building a `Global\` prefixed name only for the ACL, so two Calamari processes in different sessions took different locks and could write the journal concurrently. The lock is now consistently global on both platforms. The Everyone full-control ACL is kept (as MutexSecurity), so a lock taken by a Tentacle running as a service stays accessible to Calamari running under a different account, as does the Polly retry around creation. SystemSemaphoreManager is shared infrastructure, so WindowsX509CertificateStore gets the same recovery behaviour for its certificate store lock. Note this does not address the unbounded WaitOne() itself: a live process that holds the lock for a long time - ApplyRetention holds it across every package file deletion, each of which retries for up to a minute - will still stall other Calamari processes indefinitely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correct the retry log message, document on ISemaphoreFactory that the returned IDisposable must be disposed on the acquiring thread, and drop the test teardown that could never release the abandoned Mutex. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Acquire the lock in async code, hop onto a different thread-pool thread via Task.Delay/Task.Yield (what any real await after Acquire() does), then release from that thread and show it throws. This exercises the constraint already documented on ISemaphoreFactory.Acquire: a Windows Mutex can only be released by its owning thread, so holding the lock across an await is unsafe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A named Mutex can only be waited on and released by the thread that acquired it, but callers of ISemaphoreFactory.Acquire may reasonably dispose the returned IDisposable from a different thread than the one that called Acquire - most obviously, any async method that awaits something in between (as the previous commit's test demonstrated: ReleasingFromADifferentThreadThanAcquiredThrows). SystemSemaphoreManager now hides that constraint from callers: Acquire starts a dedicated background thread that performs the actual WaitOne()/ReleaseMutex() calls, and Acquire()/Dispose() just signal to and from it. The returned IDisposable can now be handed to, and disposed from, any thread. One consequence: the dedicated owner thread only exits once Dispose() signals it (or the process dies), so it's no longer possible to simulate an abandoned lock by having the *caller's* thread die - that thread was never the Mutex's real owner. Updated AcquireRecoversWhenTheHolderIsAbandoned to instead hold a raw named Mutex directly on the dying thread, modelling an external process/thread abandoning the lock, independent of SystemSemaphoreManager's own implementation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-ups from reviewing the dedicated owner thread added in #2159. Guard the release path. ReleaseMutex()/Dispose() run on our own thread rather than the caller's, so an exception there is unhandled and would terminate the process - a worse symptom than the hang this branch fixes. Releasing is now best effort and logs instead: a mutex we failed to release is abandoned, which the next waiter already recovers from. Dispose() still runs if ReleaseMutex() throws, so a failure doesn't leak the handle too. Dispose the two ManualResetEventSlims. Both waits block, so both allocate kernel handles that were only being reclaimed by the finaliser, once per acquisition. They are disposed after owner.Join() so the owner thread can never touch a disposed event, which in turn means a second Dispose() would have thrown ObjectDisposedException where it used to be harmless - so the Releaser is now idempotent. Dispose the Mutex on the acquisition failure path, where the handle was previously left to the finaliser. Nothing is released there, correctly: if WaitOne() threw we never owned it. ReleasingFromADifferentThreadThanAcquiredSucceeds now releases from an explicit thread instead of awaiting until a thread-pool continuation happens to land elsewhere. That loop was unbounded and had no timeout, so on a one-or-two core agent it could spin forever and hang CI rather than fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The behaviours under test are properties of Mutex on every OS .NET supports, not just Windows, so the tests move into the shared fixture base. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
We should rename this class (and interface) as we are no longer using Semaphores
| // Guards against a caller disposing twice: the events are gone after the first time through | ||
| var released = 0; |
There was a problem hiding this comment.
This logic should exist in the Releaser, where the disposal is actually occurring
There was a problem hiding this comment.
I've refactored this somewhat, changing "releaser" into a more readable "tracked thread". I think it's much nicer, good call.
The previous holder died inside the critical section, so whatever it was protecting may be inconsistent. Without this line the deployment log gave no hint that had happened. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The double-dispose guard and the event/thread teardown now live in the type that owns them, rather than in a closure over locals in Acquire(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ISemaphoreFactory becomes INamedLockManager and SystemSemaphoreManager becomes MutexBasedNamedLockManager, with the namespace and test fixtures following. The lock name strings are unchanged. This is a breaking change for external consumers of the Octopus.Calamari.Common package that reference ISemaphoreFactory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The acquired/release events and the owner thread now live together in TrackedThread rather than being threaded through the Releaser by hand. No behaviour change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| log = ConsoleLog.Instance; | ||
| initialWaitBeforeShowingLogMessage = (int)TimeSpan.FromSeconds(3).TotalMilliseconds; | ||
|
|
||
| mutexAcquisitionPipeline = new ResiliencePipelineBuilder() |
There was a problem hiding this comment.
I think this is where we create (new) up the mutex rather than acquire it. The name is confusing.
There was a problem hiding this comment.
I could be wrong, but I think if the mutex is abandoned, you can take over it (acquire it, if you will)
| Delay = TimeSpan.FromMilliseconds(50), | ||
| OnRetry = args => | ||
| { | ||
| log.Verbose($"Waiting {args.RetryDelay.TotalMilliseconds}ms before attempting to acquire the Mutex again"); |
There was a problem hiding this comment.
acquire? I think "create makes more sense"
| release.Set(); | ||
| owner.Join(); | ||
| acquired.Dispose(); | ||
| release.Dispose(); |
There was a problem hiding this comment.
I guess calamari is a throw away process so it wont matter, but we probably should try finally each one of these.
There was a problem hiding this comment.
I threw fable at this, because I'm not super well versed in what can be thrown here. Turns out nothing that we haven't already guarded against, I'm comfortable with this as-is
| // Create/acquire the global mutex with some retry, to (hopefully) avoid two instances of | ||
| // Calamari racing to create it (e.g. parallel steps on the same machine) | ||
| mutex = mutexAcquisitionPipeline.Execute(() => new Mutex(false, globalName)); | ||
|
|
There was a problem hiding this comment.
I think adding some verbose logging into where we get up to would be handy to diagnose issues where we see calamari seemingly hang, I think one before and after mutex create. And one just before the attempt to WaitOne. That ill let us know does it reach creation, could it create it, could it get past user stuff?
Oh maybe also a "mutex acquired" one would be good.
APErebus
left a comment
There was a problem hiding this comment.
I'm comfortable with this change. Thank you!
| log = ConsoleLog.Instance; | ||
| initialWaitBeforeShowingLogMessage = (int)TimeSpan.FromSeconds(3).TotalMilliseconds; | ||
|
|
||
| mutexAcquisitionPipeline = new ResiliencePipelineBuilder() |
There was a problem hiding this comment.
I could be wrong, but I think if the mutex is abandoned, you can take over it (acquire it, if you will)
| release.Set(); | ||
| owner.Join(); | ||
| acquired.Dispose(); | ||
| release.Dispose(); |
Related to LEV-1901 Stop Calamari hanging on clean packages when process is killed
Problem
On Windows,
SystemSemaphoreManager.Acquirebacked the package journal lock with a namedSemaphore. ASemaphorehas no notion of ownership, so when the process holding it goes away without running theReleaser— killed mid-ApplyRetention, Tentacle restart, OOM — the count is never restored.The
catch (AbandonedMutexException)already in that method could therefore never fire on Windows, because a semaphore cannot be abandoned. Any other process that already holds a handle keeps the kernel object (and its zeroed count) alive, so the unboundedWaitOne()waits forever.The visible symptom is a deployment that hangs during package cleanup or registration, with nothing in the logs but a single verbose
Another process is using the package journal.Fix
1. Use a named
Mutexon every platformAn abandoned mutex is signalled by the kernel and handed to the next waiter, so the existing
AbandonedMutexExceptionhandler now does the job it was written for andAcquire()recovers. Recovery is logged as a warning, since the previous holder died inside the critical section and whatever it was protecting may be inconsistent. The separate Windows (Semaphore) and *nix (Mutex) code paths collapse into one; the only platform-specific step left is the ACL below.Deliberately preserved:
SemaphoreSecuritytoMutexSecurity/MutexAccessRuleand still Windows-only. Without it aGlobal\mutex gets a default DACL, and a lock taken by a Tentacle running as a service could be unopenable by Calamari under a different account.This also corrects a naming inconsistency introduced in #1414: the Windows path created the semaphore under the unprefixed
namewhile building aGlobal\prefixed name only for the ACL. Two Calamari processes in different sessions therefore took different locks and could write the journal concurrently. The lock is now consistentlyGlobal\-prefixed on both platforms.2. Own the
Mutexon a dedicated threadA
Mutexis thread-affine: it can only be released by the thread that acquired it.Acquirereturns anIDisposable, and callers may dispose it from a different thread than the one that calledAcquire— most obviously any async method that awaits in between. The old *nix path already had this problem; switching Windows to aMutexwould have spread it.Acquire()therefore starts a background thread that performs the realWaitOne()/ReleaseMutex(), andAcquire()/Dispose()just signal to and from it. The returnedIDisposablecan be handed to, and disposed from, any thread. Disposing twice is a no-op. Acquisition failures are rethrown on the caller's thread with the original stack. A failure during release is logged rather than crashing the process, and leaves the mutex abandoned for the next waiter to recover.The owner thread, the two
ManualResetEventSlims and the double-dispose guard are all held by theReleaseritself, so the teardown lives in the type that does the disposing. The acquisition-failure path inAcquire()reusesReleaser.Dispose()rather than repeating the cleanup.3. Rename
SemaphoretoNamedLockNothing here is a semaphore any more, so the names no longer say one:
ISemaphoreFactoryINamedLockManagerSystemSemaphoreManagerMutexBasedNamedLockManagerCalamari.Common.Features.Processes.SemaphoresCalamari.Common.Features.Processes.NamedLocksSemaphoreFixtureBaseNamedLockFixtureBaseFields, parameters and the
SemaphoreNameconstants follow. The lock name strings are deliberately unchanged ("Octopus.Calamari.DeploymentJournal",nameof(WindowsX509CertificateStore),nameof(PackageJournal)) so the rename adds no new mixed-version exclusion gap beyond theGlobal\one above.Tests
Three tests in
NamedLockFixtureBase, so they run on both the Windows and *nix fixtures:AcquireRecoversWhenTheHolderIsAbandoned— a thread takes a raw namedMutexunder the sameGlobal\name and exits without releasing it, modelling an external process dying mid-critical-section.Acquire()must return within 15s. Fails onmainon Windows (times out) and passes with this branch.ReleasingFromADifferentThreadThanAcquiredSucceeds— acquires on one thread, disposes on another, then reacquires to prove the lock was really released. Fails without the owner-thread change (ApplicationExceptionfromReleaseMutex).DisposingTheReleaserTwiceIsANoOp— documents theIDisposablecontract. Worth being honest about this one: it passes with and without theInterlockedguard, becauseManualResetEventSlim.Set()on a disposed event does not throw andJoin()on a finished thread returns immediately. Double disposal was already benign; the guard is belt-and-braces, and this test pins the contract rather than covering a demonstrated failure.The first two were checked red-then-green by running the fixture at the relevant commits. The package-retention and journal fixtures were also run and pass, and the full solution builds clean.
Risks
ISemaphoreFactorywas public API on theOctopus.Calamari.Commonpackage. Renaming it toINamedLockManageris a source and binary break for anything outside this repo that referenced it. We decided to rename outright and accept the break rather than carry an[Obsolete]shim — worth a second opinion if anyone knows of an external consumer.MutexBasedNamedLockManageris the onlyINamedLockManager, so every caller gets the new behaviour:DeploymentJournal,ScriptExecutor(CalamariSynchronizeProcess),ApplicationDirectory(extraction directory), andWindowsX509CertificateStore. All of them useusingaround a synchronous block, so none should notice, but worth a look from the owners.Global\-prefixed, an old and a new Calamari running concurrently on the same machine will not exclude each other. This is transient and no worse than the cross-session gap the old code already had, but it is worth knowing during a rollout.Acquire()spins up a background thread that lives untilDispose(). Locks are short-lived and never held concurrently in large numbers, so this is negligible, but a leakedReleasernow also leaks a thread (background, so it will not block process exit).Acquire()waits on its own owner thread, so a caller that nests twoAcquire()calls for the same name on one thread blocks forever with no timeout. On Windows this matches the oldSemaphore(1,1). On *nix the old code took theMutexon the caller's thread, and aMutexis re-entrant for its owning thread, so nested acquisition used to succeed there. Every caller was checked (PackageJournal,DeploymentJournal,ScriptExecutor,ApplicationDirectory,WindowsX509CertificateStore) and none nests today.Not addressed
The unbounded
WaitOne()itself. A live process that holds the lock for a long time will still stall others indefinitely — andApplyRetentionholds the lock across every package file deletion, each of which retries for up to a minute. Abandonment now recovers; a slow holder does not. Candidates for a follow-up are a bounded wait that fails the step with a clear message, and moving file deletion outside the critical section.🤖 Generated with Claude Code