Skip to content

Use a Mutex on Windows so an abandoned package journal lock can be recovered - #2157

Merged
xwipeoutx merged 16 commits into
mainfrom
levi/sl/package-journal-semaphore-hang
Sep 17, 2026
Merged

xwipeoutx merged 16 commits into
mainfrom
levi/sl/package-journal-semaphore-hang

Conversation

@xwipeoutx

@xwipeoutx xwipeoutx commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⚠️ Does this change require a corresponding Server Change? No — the named lock manager is internal to Calamari. See "Risks" below for a public-API note and a mixed-version note.

Related to LEV-1901 Stop Calamari hanging on clean packages when process is killed

Problem

On Windows, SystemSemaphoreManager.Acquire backed the package journal lock with a named Semaphore. A Semaphore has no notion of ownership, so when the process holding it goes away without running the Releaser — 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 unbounded WaitOne() 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 Mutex on every platform

An abandoned mutex is signalled by the kernel and handed to the next waiter, so the existing AbandonedMutexException handler now does the job it was written for and Acquire() 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:

  • The Everyone full-control ACL, ported from SemaphoreSecurity to MutexSecurity/MutexAccessRule and still Windows-only. Without it a Global\ 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.
  • The Polly creation retry from Add retry to Semaphore acquisition #1414, and the 3-second-then-log wait behaviour.

This also corrects a naming inconsistency introduced in #1414: the Windows path created the semaphore under the unprefixed name while building a Global\ 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 consistently Global\-prefixed on both platforms.

2. Own the Mutex on a dedicated thread

A Mutex is thread-affine: it can only be released by the thread that acquired it. Acquire returns an IDisposable, and callers may dispose it from a different thread than the one that called Acquire — most obviously any async method that awaits in between. The old *nix path already had this problem; switching Windows to a Mutex would have spread it.

Acquire() therefore starts a background thread that performs the real WaitOne()/ReleaseMutex(), and Acquire()/Dispose() just signal to and from it. The returned IDisposable can 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 the Releaser itself, so the teardown lives in the type that does the disposing. The acquisition-failure path in Acquire() reuses Releaser.Dispose() rather than repeating the cleanup.

3. Rename Semaphore to NamedLock

Nothing here is a semaphore any more, so the names no longer say one:

Before After
ISemaphoreFactory INamedLockManager
SystemSemaphoreManager MutexBasedNamedLockManager
Calamari.Common.Features.Processes.Semaphores Calamari.Common.Features.Processes.NamedLocks
SemaphoreFixtureBase NamedLockFixtureBase

Fields, parameters and the SemaphoreName constants 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 the Global\ one above.

Tests

Three tests in NamedLockFixtureBase, so they run on both the Windows and *nix fixtures:

  • AcquireRecoversWhenTheHolderIsAbandoned — a thread takes a raw named Mutex under the same Global\ name and exits without releasing it, modelling an external process dying mid-critical-section. Acquire() must return within 15s. Fails on main on 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 (ApplicationException from ReleaseMutex).
  • DisposingTheReleaserTwiceIsANoOp — documents the IDisposable contract. Worth being honest about this one: it passes with and without the Interlocked guard, because ManualResetEventSlim.Set() on a disposed event does not throw and Join() 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

  • ISemaphoreFactory was public API on the Octopus.Calamari.Common package. Renaming it to INamedLockManager is 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.
  • Wider blast radius than the package journal. MutexBasedNamedLockManager is the only INamedLockManager, so every caller gets the new behaviour: DeploymentJournal, ScriptExecutor (CalamariSynchronizeProcess), ApplicationDirectory (extraction directory), and WindowsX509CertificateStore. All of them use using around a synchronous block, so none should notice, but worth a look from the owners.
  • Mixed-version window. Because the Windows lock name changes from unprefixed to 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.
  • One thread per held lock. Each Acquire() spins up a background thread that lives until Dispose(). Locks are short-lived and never held concurrently in large numbers, so this is negligible, but a leaked Releaser now also leaks a thread (background, so it will not block process exit).
  • Nested acquisition self-deadlocks, and on *nix this is a behaviour change. Each Acquire() waits on its own owner thread, so a caller that nests two Acquire() calls for the same name on one thread blocks forever with no timeout. On Windows this matches the old Semaphore(1,1). On *nix the old code took the Mutex on the caller's thread, and a Mutex is 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 — and ApplyRetention holds 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

xwipeoutx and others added 10 commits September 15, 2026 08:20
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>
@xwipeoutx
xwipeoutx marked this pull request as ready for review September 16, 2026 02:59
Comment thread source/Calamari.Common/Features/Processes/Semaphores/ISemaphoreFactory.cs Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should rename this class (and interface) as we are no longer using Semaphores

Comment on lines +126 to +127
// Guards against a caller disposing twice: the events are gone after the first time through
var released = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic should exist in the Releaser, where the disposal is actually occurring

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've refactored this somewhat, changing "releaser" into a more readable "tracked thread". I think it's much nicer, good call.

Comment thread source/Calamari.Common/Features/Processes/Semaphores/SystemSemaphoreManager.cs Outdated
Comment thread source/Calamari.Common/Features/Processes/Semaphores/SystemSemaphoreManager.cs Outdated
xwipeoutx and others added 5 commits September 16, 2026 15:18
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>
@xwipeoutx
xwipeoutx requested a review from APErebus September 17, 2026 00:14
log = ConsoleLog.Instance;
initialWaitBeforeShowingLogMessage = (int)TimeSpan.FromSeconds(3).TotalMilliseconds;

mutexAcquisitionPipeline = new ResiliencePipelineBuilder()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is where we create (new) up the mutex rather than acquire it. The name is confusing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acquire? I think "create makes more sense"

Comment on lines +178 to +181
release.Set();
owner.Join();
acquired.Dispose();
release.Dispose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess calamari is a throw away process so it wont matter, but we probably should try finally each one of these.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, that's fine

// 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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 APErebus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm comfortable with this change. Thank you!

log = ConsoleLog.Instance;
initialWaitBeforeShowingLogMessage = (int)TimeSpan.FromSeconds(3).TotalMilliseconds;

mutexAcquisitionPipeline = new ResiliencePipelineBuilder()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be wrong, but I think if the mutex is abandoned, you can take over it (acquire it, if you will)

Comment on lines +178 to +181
release.Set();
owner.Join();
acquired.Dispose();
release.Dispose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, that's fine

@xwipeoutx
xwipeoutx merged commit b7f25bd into main Sep 17, 2026
29 checks passed
@xwipeoutx
xwipeoutx deleted the levi/sl/package-journal-semaphore-hang branch September 17, 2026 01:40
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.

3 participants