Skip to content

Fix NetworkWriter.AsyncFlushPages hanging FlushEvent waiters on send failure#1949

Open
hexonal wants to merge 5 commits into
microsoft:mainfrom
hexonal:fix-networkwriter-flush-hang
Open

Fix NetworkWriter.AsyncFlushPages hanging FlushEvent waiters on send failure#1949
hexonal wants to merge 5 commits into
microsoft:mainfrom
hexonal:fix-networkwriter-flush-hang

Conversation

@hexonal

@hexonal hexonal commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Context

This is a follow-up on the "main thread blocking / hang" symptom described in #1929. #1929 traces back to a NullReferenceException in GarnetServerNode.TryClusterPublish after a gossip timeout, which is fixed by the (currently unmerged) maintainer PR #1931 via an !IsConnected guard.

This PR does not touch that code path and does not claim to resolve #1929 on its own. It fixes a separate bug that is reachable from the same general area (a broken/disposed cluster connection during a flush) and produces the same class of symptom — a caller thread hanging indefinitely instead of failing fast. Whether or not it is the root cause of every hang reported under #1929 I can't say with confidence; what I can say precisely is described below.

Root cause

NetworkWriter.AsyncFlushPages sends each buffered page via networkSender.SendResponse(...), passing a PageAsyncFlushResult as the completion context. Normally, SendResponse (synchronously or via a completion callback) eventually invokes AsyncFlushPageCallback(context), which decrements PageAsyncFlushResult.count.count and, once it reaches zero, calls FlushEvent.Set().

If SendResponse throws (e.g. because the underlying connection was concurrently disposed/broken — which is exactly what happens when a gossip timeout tears down a cluster connection), the existing code:

catch (Exception ex)
{
    logger?.LogError(ex, "Exception calling networkSender.SendResponse in AsyncFlushPages");
    networkHandler.Dispose();
}

logs the error and disposes the handler, but never invokes AsyncFlushPageCallback for that page. FlushEvent is therefore never signaled for this flush. Any thread blocked on it — most notably GarnetClient.InternalExecuteNoResponse, which flushEvent.Wait()s synchronously and is used by cluster PUBLISH forwarding — hangs indefinitely (or until an external timeout/cancellation, if the caller supplied one).

Fix

Explicitly invoke AsyncFlushPageCallback(asyncResult) in the catch block so the completion counter is still decremented and FlushEvent still gets signaled when a send fails, matching what would have happened on a successful send. 3-line change in libs/client/NetworkWriter.cs.

Known related limitation (not fixed here)

ClientTcpNetworkSender.SendResponse can complete synchronously and inline (when the underlying socket.SendAsync returns false), which means AsyncFlushPageCallback can already be running inline underneath SendResponse when it throws. In the narrow case where AsyncFlushPageCallback itself throws (e.g. from epoch.BumpCurrentEpoch, reachable from inside it), this fix's catch block would invoke AsyncFlushPageCallback a second time for a result whose completion counter was already decremented once, which could corrupt count.count for that flush batch. I have not observed this in practice and did not reproduce it — flagging it as a known, narrow, pre-existing-adjacent edge case rather than silently leaving it unaddressed. Happy to follow up separately if it's judged worth guarding against explicitly.

Testing

  • Added AsyncFlushPagesSignalsFlushEventOnSendFailure in test/standalone/Garnet.test/GarnetClientTests.cs: parks a thread on flushEvent.Wait() (mirroring a caller already blocked when the connection dies), swaps in a network sender stub whose SendResponse always throws, then triggers AsyncFlushPages and asserts the waiter is released rather than timing out.
    • Verified the test reproduces the bug: with the 3-line fix reverted, the test fails with OperationCanceledException after a 3s cancellation timeout (a real hang). With the fix restored, it passes in under a second.
    • The test needs InternalsVisibleTo on Garnet.client for the test assembly (NetworkWriter is internal sealed); added to libs/client/Garnet.client.csproj using the repo's existing signing key (same key already used by libs/host/Garnet.host.csproj and others).
  • Isolated test run: 15/15 passes.
  • Full GarnetClientTests class (20 tests): 8/8 full-class runs green, no flakes observed.
  • dotnet build succeeds for Garnet.client.csproj, Garnet.cluster.csproj, and Garnet.test.csproj.
  • dotnet format --verify-no-changes is clean on the changed files.
  • I did not reproduce the original cluster/gossip-timeout scenario from Bug: Gossip timeout causes GarnetClient disposal cascade, ObjectDisposedException, and process unresponsiveness #1929/Fix cluster publish forwarding when peer connection is down #1931 end-to-end; testing here is at the NetworkWriter unit level described above, not a full cluster integration repro.

hexonal added 2 commits July 20, 2026 05:12
…failure

When networkSender.SendResponse throws inside AsyncFlushPages (e.g. because
the connection was disposed concurrently, such as after a cluster gossip
timeout), the catch block logged the exception and disposed the handler but
never invoked AsyncFlushPageCallback for that page. That callback is the
only place that decrements the page-completion counter and signals
FlushEvent, so any caller already blocked on flushEvent.Wait (e.g.
GarnetClient.InternalExecuteNoResponse, used by cluster PUBLISH forwarding)
would hang until the next gossip round disposed the whole NetworkWriter.

This addresses the remaining "main thread blocking" symptom described in
microsoft#1929 that is not covered by microsoft#1931, which only fixes the separate
NullReferenceException in GarnetServerNode.TryClusterPublish. This change
does not touch that code path and does not claim to fully resolve microsoft#1929.

Adds a regression test that reproduces the hang by forcing a send failure
while a thread is parked on the flush-completion event, and verifies it is
released instead of only unblocking via a cancellation timeout. The test
needs direct access to NetworkWriter's internals to inject the failure and
observe the specific completion-event generation, so Garnet.client grants
InternalsVisibleTo to Garnet.test (matching the pattern already used by
other libs projects).
Replace the fixed Thread.Sleep(50) rendezvous in
AsyncFlushPagesSignalsFlushEventOnSendFailure with a deterministic
readiness signal: the waiter thread flips a volatile flag immediately
before calling flushEvent.Wait, and the main thread spins on that flag
(bounded by SpinWait, no fixed delay) before triggering the failing
flush. This removes the timing assumption an independent review flagged
as a source of a low-frequency flake under load, in a test whose entire
purpose is proving the fix doesn't hang.
Copilot AI review requested due to automatic review settings July 20, 2026 09:37

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 fixes a client networking hang where NetworkWriter.AsyncFlushPages could swallow exceptions from INetworkSender.SendResponse without signaling FlushEvent, leaving callers blocked indefinitely (notably in cluster-related forwarding paths that synchronously wait for flush completion).

Changes:

  • Ensure AsyncFlushPages signals completion even when SendResponse throws by invoking the page flush callback on the exception path.
  • Add a regression test that simulates a send failure and asserts FlushEvent waiters are released.
  • Allow the test assembly to access Garnet.client internals via InternalsVisibleTo.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
test/standalone/Garnet.test/GarnetClientTests.cs Adds a regression test that forces a send failure during AsyncFlushPages and verifies FlushEvent waiters do not hang.
libs/client/NetworkWriter.cs Ensures flush completion bookkeeping runs even when SendResponse throws, avoiding indefinite waits.
libs/client/Garnet.client.csproj Grants Garnet.test access to internal client types used by the new regression test.

Comment on lines 334 to +338
logger?.LogError(ex, "Exception calling networkSender.SendResponse in AsyncFlushPages");
networkHandler.Dispose();
// Still signal completion for this page so FlushEvent waiters (e.g. GarnetClient
// callers blocked in InternalExecuteNoResponse) are not left hanging indefinitely.
AsyncFlushPageCallback(asyncResult);

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.

@hexonal, this seems serious. Can we guarantee that AsyncFlushPages does not decrement the value twice?
In the worst case, it might put the count into a state that creates the same issue that you were trying to resolve.

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 went through this carefully because it's a fair concern. AsyncFlushPageCallback does Interlocked.Decrement(ref result.count.count), and only signals FlushEvent once that hits zero.

Looking at ClientTcpNetworkSender.SendResponse, every exception it can throw falls into one of two buckets: either it happens before the SocketAsyncEventArgs is even dispatched (throttle wait, checkout, buffer setup — no callback has run yet), or socket.SendAsync() itself throws synchronously, which per the SAEA contract means the operation was never actually queued, so Completed can never fire for it afterward. If SendAsync succeeds instead, SendResponse just returns normally and the catch block never runs for that page at all.

So there's no path where a page gets counted twice — the callback fires from exactly one place per page, either the normal completion or (only when the send never got queued) the new catch-block call, never both. I didn't just reason through this abstractly, I diffed the actual send path line by line against main to make sure I wasn't missing a partial-completion case.

Comment on lines +621 to +626
var waiterThread = new Thread(() =>
{
Volatile.Write(ref aboutToWait, true);
try { flushEvent.Wait(cts.Token); }
catch (Exception ex) { waiterException = ex; }
})

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.

Good catch, fixed in c4e561d — you're right that flipping aboutToWait before actually calling Wait() still left a real window.

Went one step further than the suggested fix, though: instead of registering the wait via WaitAsync() on a background thread and blocking on the returned task there, I realized there's no need for a separate thread at all. WaitAsync() registers with the underlying SemaphoreSlim synchronously, before it returns — so the test can just call flushEvent.WaitAsync(cts.Token) on the main thread, trigger the failing flush right after (no rendezvous needed, since registration already happened), and then block on the returned Task with a timeout. That removes the aboutToWait flag, the SpinWait, and the background thread entirely, not just the race between them.

Ran it 5x in a row locally plus the full GarnetClientTests suite (20/20) to sanity-check.

@hexonal

hexonal commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

One red job on the last run: Garnet Standalone (ubuntu-latest, net10.0, Debug, Garnet.test) — not a test failure, the whole Run tests Garnet.test step timed out after 45 minutes. Log shows this PR's own new test (AsyncFlushPagesSignalsFlushEventOnSendFailure) passed fine early on (16ms); the actual hang happened later, after RespAdminCommandsTests.SeSaveRecoverMultipleKeysTest("16k","16k") completed, with no further progress logged for 44 minutes before the timeout. Looks like the same class of intermittent persistence-test hang addressed before in #1932, unrelated to this PR's diff.

@hexonal

hexonal commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Update: filed #1950 — turns out this test has a real, reliably-reproducible bug (not just flakiness), likely related to the recovery/eviction interaction #1932 touched. See there for repro steps and analysis.

@badrishc badrishc assigned badrishc and vazois and unassigned badrishc Jul 21, 2026
hexonal added 2 commits July 21, 2026 23:20
Per Copilot review feedback: setting the aboutToWait flag before actually
calling flushEvent.Wait() still left a narrow window where Set()/Dispose()
on the underlying SemaphoreSlim could race the waiter thread's own call to
Wait(), occasionally surfacing as an ObjectDisposedException instead of
exercising the intended flow.

Switch to flushEvent.WaitAsync(), which registers with the semaphore
synchronously before returning. This closes the race entirely rather than
narrowing it, and removes the need for a separate waiter thread and
readiness rendezvous altogether: the test can just trigger the flush and
then block on the returned Task with a timeout.
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.

Bug: Gossip timeout causes GarnetClient disposal cascade, ObjectDisposedException, and process unresponsiveness

4 participants