Skip to content

Fix SynchronizationLockException on pub/sub self-publish - #1948

Closed
hexonal (hexonal) wants to merge 3 commits into
microsoft:mainfrom
hexonal:fix-pubsub-lock-reentrancy
Closed

Fix SynchronizationLockException on pub/sub self-publish#1948
hexonal (hexonal) wants to merge 3 commits into
microsoft:mainfrom
hexonal:fix-pubsub-lock-reentrancy

Conversation

@hexonal

Copy link
Copy Markdown
Contributor

Fixes #1615 (the network-lock-error part of that issue; the RESP2 command-restriction part — Garnet not yet rejecting non-(P)SUBSCRIBE/PING/QUIT/RESET commands while in subscribe mode — is a separate, larger concern and out of scope for this PR).

Root cause

GarnetTcpNetworkSender's spinLock field is a SpinLock struct initialized with new() and no user-declared parameterless constructor. That zero-inits _owner, which per SpinLock semantics enables thread-owner tracking (equivalent to new SpinLock(true)), so a same-thread reentrant Enter() throws.

A session can trigger a reentrant call into its own network sender: PUBLISH on a connection that is itself subscribed to the target channel causes SubscribeBroker.PublishNow/Broadcast to call back into that same connection's Publish()/PatternPublish() synchronously, while TryConsumeMessages for the PUBLISH command is still on the stack and still holding the sender's SpinLock. Garnet does not currently enforce RESP2's "only a few commands allowed while subscribed" restriction, so this is reachable without RESP3.

EnterAndGetResponseObject on that reentrant call throws before touching lockTaken/responseObject, but the old code's finally block unconditionally called ExitAndReturnResponseObject(). Since SpinLock.Exit()'s ownership check is by thread ID (not call-frame/recursion depth), the erroneous reentrant Exit() succeeded and silently released the outer frame's lock early. The outer TryConsumeMessages finally then called Exit() again on an already-released lock, throwing an uncaught SynchronizationLockException and tearing down the connection.

As a secondary effect, the same unconditional finally also returned responseObject to the free buffer pool on the reentrant frame — but responseObject there is still the outer frame's actively-in-use buffer (never reset, since the failed Enter never got that far), so the old code was also returning a buffer to the pool while it was still being written to.

Fix

In PubSubCommands.cs, Publish() and PatternPublish() now track whether EnterAndGetResponseObject actually succeeded (entered, following the same lockTaken-guard pattern already used in EnterAndGetResponseObject/GarnetTcpNetworkSender.cs) and only call ExitAndReturnResponseObject() in finally if it did. This also incidentally fixes the buffer-pool double-use described above, since the erroneous Exit/return no longer happens.

The Debug.Assert(isSubscriptionSession == false) in NetworkPUBLISH is removed: in Debug builds without a debugger attached, Debug.Assert failure calls Environment.FailFast and kills the whole process, so this exact scenario would crash the test process outright regardless of the lock fix. The assert was asserting a false invariant anyway — self-publish-while-subscribed is a real, currently-unrestricted scenario.

Behavior note / known tradeoff

With this fix, a message a session publishes to a channel it is itself (pattern-)subscribed to is not delivered back to that same connection — the reentrant delivery attempt is now a no-op instead of corrupting the connection, so the self-message is silently dropped rather than crashing. PUBLISH itself still completes normally and still reports the correct subscriber count. This is a deviation from full Redis pub/sub semantics (a subscriber normally does receive its own publish) and is a deliberate, minimal tradeoff to fix the crash without restructuring the network-sender locking or deferring self-delivery to after the outer lock is released. Delivering the self-message correctly (e.g. by queuing it for delivery once the outer TryConsumeMessages lock is released) would be a larger change and is left as potential follow-up; happy to discuss if maintainers would prefer that approach instead.

Testing performed

  • dotnet build test/standalone/Garnet.test/Garnet.test.csproj in both Debug and Release: both succeed, 0 warnings, 0 errors.
  • dotnet test ... --filter "FullyQualifiedName~RespPubSubTests" on net10.0, Debug and Release: 8/8 pass (7 pre-existing + 2 new regression tests, one for SUBSCRIBE/Publish and one for PSUBSCRIBE/PatternPublish).
  • Reverted the entered-guard fix in Publish() only (kept the Debug.Assert removal) and reran the new SelfPublishOnSubscribedChannelDoesNotCorruptConnection test alone: it fails, with the connection dying mid-PUBLISH as expected. Restored the fix: passes again, confirmed stable across repeated runs.
  • dotnet format --verify-no-changes on the changed files: clean.
  • git diff --check: no whitespace/line-ending issues.
  • Manual review of the diff against existing lock-guard conventions elsewhere in the same files (GarnetTcpNetworkSender.cs, EnterAndGetResponseObject).
  • Not run: the full Garnet test suite (only the pub/sub-focused subset above was targeted) and any cluster/RESP3-specific pub/sub paths beyond what these tests cover.

Diff touches libs/server/Resp/PubSubCommands.cs and test/standalone/Garnet.test/RespPubSubTests.cs only.

A client that PUBLISHes to a channel it is itself subscribed to is
delivered its own message via a reentrant call into that same
connection's Publish()/PatternPublish(), while the connection's
TryConsumeMessages is still holding the network sender's SpinLock on
the same thread. SpinLock.Enter() throws when re-entered by its
owning thread; the old finally block still unconditionally called
Exit(), releasing a lock this call never actually acquired, so the
outer TryConsumeMessages later threw SynchronizationLockException
when releasing the lock it did hold, tearing down the connection.

Track whether Enter actually succeeded before Exiting in finally, so
a failed reentrant Enter no longer causes an extra Exit. The
self-delivery is then silently dropped for that one connection
instead of corrupting the session's lock state.

Also removes a stale Debug.Assert(isSubscriptionSession == false) in
NetworkPUBLISH: self-publish while subscribed is a legitimate
scenario (RESP3 allows arbitrary commands while subscribed, and
Garnet does not yet enforce RESP2's equivalent restriction - a
separate, larger concern tracked in microsoft#1615), and the assert was
FailFast-crashing Debug builds on exactly this path.

Fixes the network-lock-error part of microsoft#1615.
The Publish() and PatternPublish() entered-guard fix is identical code
applied to both paths, but only Publish()/SUBSCRIBE had a regression
test. Add the matching test for PSUBSCRIBE/PatternPublish so both
reentrant-delivery paths are directly covered.
Copilot AI review requested due to automatic review settings July 20, 2026 09:30

Copilot AI 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.

Pull request overview

This PR addresses a crash scenario in Garnet’s pub/sub path where a connection can re-enter its own network-sender SpinLock during self-publish delivery, leading to an eventual SynchronizationLockException and connection teardown. The fix ensures the sender lock/buffer are only released when they were actually acquired, and adds regression coverage for both SUBSCRIBE and PSUBSCRIBE self-publish scenarios.

Changes:

  • Guard ExitAndReturnResponseObject() in Publish() and PatternPublish() behind an entered flag so re-entrant failures do not unlock/return buffers they never acquired.
  • Remove the Debug.Assert(isSubscriptionSession == false) in NetworkPUBLISH and replace it with an explanatory comment (avoids FailFast in Debug and reflects real reachable behavior).
  • Add two socket-level regression tests that reproduce the self-publish-on-subscribed-connection scenario and verify the connection remains usable afterward.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
libs/server/Resp/PubSubCommands.cs Adds an entered guard to prevent incorrect lock exit/buffer return on re-entrant pub/sub delivery; removes unsafe Debug assert in PUBLISH.
test/standalone/Garnet.test/RespPubSubTests.cs Adds regression tests for self-publish on subscribed and pattern-subscribed connections, plus helper routines to send/receive raw RESP.

Comment on lines +297 to +307
private static void SendCommand(Socket socket, params string[] args)
{
var sb = new StringBuilder();
sb.Append('*').Append(args.Length).Append("\r\n");
foreach (var arg in args)
{
var argBytes = Encoding.UTF8.GetByteCount(arg);
sb.Append('$').Append(argBytes).Append("\r\n").Append(arg).Append("\r\n");
}
socket.Send(Encoding.UTF8.GetBytes(sb.ToString()));
}
Badrish Chandramouli (badrishc) added a commit that referenced this pull request Jul 21, 2026
…ntrant lock crash

Fixes #1615. Two related bugs:

1. RESP2 subscription mode allowed arbitrary commands (GET, SET, PUBLISH, etc.)
   instead of restricting to only (P|S)SUBSCRIBE/(P|S)UNSUBSCRIBE/PING/QUIT per
   the RESP protocol. Added IsAllowedInSubscriptionMode() check in ProcessMessages
   that rejects disallowed commands with a RESP-compatible error message.

2. PUBLISH from a subscriber session caused SynchronizationLockException because
   the Publish() callback re-entered the sender's thread-tracked spinlock already
   held by TryConsumeMessages. Fixed by tracking the command-processing thread ID
   and detecting reentrant calls in Publish()/PatternPublish() to skip lock
   acquire/release and write the self-message directly into the existing buffer.

RESP3 sessions are not restricted since push message types are distinguishable
from regular responses.

Raw-socket regression tests for the self-publish scenario are adapted from the
alternative fix proposed in #1948 (credit: @hexonal), whose root-cause analysis
of the thread-tracked SpinLock is captured in the Publish()/PatternPublish()
comments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: hexonal <38680988+hexonal@users.noreply.github.com>
Copilot-Session: c513ca0c-128d-4f11-9edd-fcff2ace7a82
@badrishc

Copy link
Copy Markdown
Collaborator

Thank you hexonal (@hexonal) for your contributions! For this particular PR, note that there was already an open PR #1669 that handles the lock issue as well as RESP command restrictions. We have incorporated aspects such as the test and RCA from here into that one.

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.

Pub/sub: network lock error, and allows more than it should (Garnet 2.0.0 64 bit)

3 participants