Treat a write losing its connection as a closure, not an internal fault (#3167) - #3189
Merged
Conversation
…lt (#3167) Shutdown() discards the output writer, and it cannot wait for the write lock: teardown is usually spotted on the read loop, which must not block. So a writer already inside the lock can have the pipe pulled out from under it at any point. That is an ordinary closure, but the write path reported it as InternalFailure wrapping InvalidOperationException("Output pipe not initialized") - which reads as a client bug, is not a recognised connection failure, and also gets announced through ConnectionMultiplexer.InternalError. IdentifyFailureType already maps ObjectDisposedException to SocketClosed; the write path just never consulted it. Changes: - PhysicalConnection keeps an _isShutdown flag, set before the output is discarded, so a writer that finds it gone can tell a closure from a genuinely uninitialised pipe: the former now throws ObjectDisposedException, the latter still InvalidOperationException. This also removes a duplicate copy of that throw in Flush(). - New PhysicalConnection.ClassifyWriteFailure centralises the decision: honour an inner RedisConnectionException's own failure type, defer to IdentifyFailureType, and only fall back to InternalFailure when there is nothing better. PhysicalBridge (both write-failure paths) and Message.WriteTo now use it, and only report OnInternalError when it really was internal. Two further faults found while reproducing this, both in the same window: - ProcessBridgeBacklog re-read the nullable `physical` field to pass to WriteMessageInsideLock (whose parameter is not nullable) after testing it in the loop guard. OnDisconnected nulls that field, so the drain could hand it null and NRE - surfacing as InternalFailure "Failed to write". Now snapshotted per message, matching the idiom used elsewhere in the file. - OperationCanceledException was not classified. RESPite already documents it as expected teardown noise alongside ObjectDisposedException, and the read loop treats a cancelled read as SocketClosed; the write path now matches, gated on our own OutputCancel having fired so a caller's cancellation is not mislabelled. Tests: a deterministic test that shuts the output down before the write and asserts the failure is not badged internal, plus an explicit (opt-in) stress test that races real teardowns against concurrent writers - the latter is what found all three faults, but it has to saturate the box to hit the window, so it is not part of the default run.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3167.
The race
PhysicalConnection.Shutdown()discards the output writer, and it cannot wait for the write lock - teardown is usually spotted on the read loop, which must not block on it. So a writer already inside the lock can have the pipe pulled out from under it at any point. Every_outputread on the write path is therefore a TOCTOU;ProcessBridgeBacklog'sHasOutputPipeguard only narrows the window to one message, which is why a 193-deep backlog drain found it in the field.The race itself isn't fixable by locking. What's wrong is the reporting: a routine closure came out as
InternalFailurewrappingInvalidOperationException("Output pipe not initialized")- which reads as a client bug, isn't a recognised connection failure, and is also announced throughConnectionMultiplexer.InternalError.IdentifyFailureTypealready mapsObjectDisposedExceptiontoSocketClosed; the write path simply never consulted it -WriteMessageToServerInsideWriteLock,HandleWriteExceptionand all threeMessage.WriteTocatches hardcodedInternalFailure.Changes
PhysicalConnectiongains an_isShutdownflag, set before the output is discarded, so a writer that finds it gone can distinguish a closure from a genuinely uninitialised pipe. The former now throwsObjectDisposedException, the latter stillInvalidOperationException- a real bug stays loud. Also collapses a duplicate copy of that throw inFlush().PhysicalConnection.ClassifyWriteFailurecentralises the decision: honour an innerRedisConnectionException's own failure type, defer toIdentifyFailureType, and fall back toInternalFailureonly when there's nothing better.PhysicalBridge(both write-failure paths) andMessage.WriteTouse it, and only fireOnInternalErrorwhen it really was internal.All internal - no public API change.
Two further faults found while reproducing it
Both live in the same window, and both surfaced as the same
InternalFailure:NullReferenceException.ProcessBridgeBacklogre-read the nullablephysicalfield to pass toWriteMessageInsideLock(whose parameter is not nullable) after testing it in the loop guard.OnDisconnectednulls that field, so the drain could hand it null - 2-5 per stress run, reported asInternalFailure: "Failed to write". Now snapshotted per message, matching the idiom already used at three other sites in the file.OperationCanceledExceptionwasn't classified. Not speculative: RESPite already documents it as expected teardown noise alongsideObjectDisposedException(BufferedStreamWriter.Switchable.cs), and the read loop treats a cancelled read asSocketClosed. The write path now matches, gated on our ownOutputCancelhaving fired so a caller's cancellation isn't mislabelled.Behaviour change
Callers who previously saw
InternalFailurefor these races now seeSocketClosed/SocketFailure, and no longer get a spuriousInternalErrorevent. Messages are still failed rather than retried - deliberately: the connection is genuinely gone, and per-message resurrection isWithRetry's job, not the bridge's.Tests
tests/StackExchange.Redis.Tests/Issues/Issue3167Tests.cs:WriteLosingItsConnectionIsReportedAsAClosure- deterministic, runs in CI (~6ms). Shuts the output down before the write, then asserts the caller's failure isn't badged internal and noInternalErroris raised. It deliberately doesn't demand one exact exception: the message can also be completed by the teardown our ownShutdownkicked off, carrying the underlying socket error.WritesRacingTeardownAreNeverInternalFailures-Explicit = true, so opt-in. Races real teardowns against 16 concurrent writers; this is what found all three faults. It has to saturate the box to hit the window reliably, which starves anything running alongside it, so it's kept out of the default run; the docs on it give the invocation.Verified both fail against unmodified
main(Expected: SocketClosed / Actual: InternalFailure, plus 28 output-pipe faults and 35 internal errors in a 10s window) and pass here.dotnet build Build.csproj -c Release /p:CI=truegreen across all TFMs; full net10.0 and net8.0 suites green.One thing worth knowing independently of this PR: the suite has an ambient flake rate of roughly 1 run in 6, spread across a load-sensitive family (
DatabaseTests.CountKeys,PubSubKeyNotification*,UnroutableRedirectUnitTests). I confirmed that on unmodifiedmain, not just here. Separately, during teardown storms bothmainand this branch logInvalidOperationException: Received Interactive/Resp3 response with no message waiting: NullfromCommitAndParseFramesa couple of times per run - pre-existing, not chased here.