Visitar URL original
Deadlock after Connect: conditional transaction holds the write lock while the handshake's INFO replication write blocks the reader (RedisTimeoutException on UNWATCH) · Issue #3348 · seredis/StackExchange.Redis · GitHub
Skip to content

Deadlock after Connect: conditional transaction holds the write lock while the handshake's INFO replication write blocks the reader (RedisTimeoutException on UNWATCH) #3348

Open

Description

@kirill-abblix

Summary

I send a transaction with conditions (CreateTransaction(), AddCondition(...), ExecuteAsync()) as the first command on a multiplexer whose Connect or ConnectAsync has just returned. Sometimes it waits for the whole SyncTimeout and then fails with RedisTimeoutException on UNWATCH. Thread stacks taken during such a stall show two threads waiting for each other:

  • the reader thread is blocked inside the handshake's ECHO handler, waiting for the write lock;
  • the thread writing the transaction holds that lock while it waits for the condition replies, which only the reader can deliver.

Awaiting a PING on the same multiplexer before the first conditional transaction avoided it in every run below, except one unexplained late completion on 3.4.29.

I observed it on 2.12.1, 3.4.5, 3.4.20, 3.4.29 and 3.4.31. I have also seen it on Linux CI agents on 3.4.31, with the same snapshot fields: command=UNWATCH, rs: DequeueResult, a few bytes left in in-pipe.

Environment

  • Client: Windows 11 (Microsoft Windows NT 10.0.26300.0), .NET runtime 11.0.0, 16 logical CPUs.
  • Server: redis:8.10.2-alpine in Docker Desktop (engine 29.9.3, WSL2) on the same machine, default configuration, no TLS, no AUTH.
  • Endpoint 127.0.0.1:6379. Client options are defaults except SyncTimeout and AsyncTimeout, which are 5000 ms unless stated.

Thread stacks during a stall

I ran the repro in fresh mode with --timeout=60000, so that a stall lasts long enough to inspect, and took the stacks with dotnet-stack report -p <pid> (dotnet-stack 10.0.750501) while it was stalled. The two relevant threads on 3.4.31, top frames first:

StackExchange.Redis.AwaitableMutex.TryTakeSync()
StackExchange.Redis.PhysicalBridge.WriteMessageTakingWriteLockSync(PhysicalConnection, Message)
StackExchange.Redis.PhysicalBridge.TryWriteSync(Message, bool)
StackExchange.Redis.ServerEndPoint.CheckInfoReplication()
StackExchange.Redis.PhysicalBridge.OnFullyEstablished(PhysicalConnection, string)
StackExchange.Redis.ResultProcessor+TracerProcessor.SetResult(PhysicalConnection, Message, RespReader&)
StackExchange.Redis.Message.ComputeResult(PhysicalConnection, RespReader&)
StackExchange.Redis.PhysicalConnection.MatchNextResult(ReadOnlySpan<byte>)
StackExchange.Redis.PhysicalConnection.OnResponseFrame(...)
StackExchange.Redis.PhysicalConnection.CommitAndParseFrames(int)
StackExchange.Redis.PhysicalConnection+<ReadAllAsync>d__165.MoveNext()
System.Threading.Condition.Wait(int, object)
StackExchange.Redis.RedisTransaction+TransactionMessage+<GetMessages>d__14.MoveNext()
StackExchange.Redis.PhysicalBridge.WriteMessageInsideLock(PhysicalConnection, Message)
StackExchange.Redis.PhysicalBridge.WriteMessageTakingWriteLockAsync(PhysicalConnection, Message, bool)
StackExchange.Redis.PhysicalBridge.TryWriteAsync(Message, bool, bool)
StackExchange.Redis.ServerEndPoint.TryWriteAsync(Message)
StackExchange.Redis.ConnectionMultiplexer.TryPushMessageToBridgeAsync(...)
StackExchange.Redis.ConnectionMultiplexer.ExecuteAsyncImpl(...)
StackExchange.Redis.RedisBase.ExecuteAsync(...)
StackExchange.Redis.RedisTransaction.ExecuteAsync(CommandFlags)

A capture on 3.4.5, taken the same way, shows the same two stacks.

What happens

References are to the 3.4.31 tag. I traced the same call order in 3.4.5; the stacks above confirm it on both versions.

  • The handshake ends with the tracer ECHO <UniqueId>. Its reply is handled on the reader thread: TracerProcessor.SetResult calls PhysicalBridge.OnFullyEstablished (ResultProcessor.cs:3448).
  • PhysicalBridge.OnFullyEstablished (PhysicalBridge.cs:556) first calls ServerEndPoint.OnFullyEstablished (line 567), which completes the pending connect waits (CompletePendingConnectionMonitors, ServerEndPoint.cs:1166, defined at 981). Those waits run their continuations asynchronously (ServerEndPoint.cs:209), so Connect returns on another thread while the reader is still inside the handler.
  • The caller sends its conditional transaction straight away. WriteMessageInsideLock walks TransactionMessage.GetMessages under the write lock (PhysicalBridge.cs:866). After writing WATCH and the condition commands, GetMessages flushes and calls Monitor.Wait(lastBox, TimeoutMilliseconds) (RedisTransaction.cs:373-375), still holding the lock.
  • The reader, still inside the handler, then calls ServerEndPoint.CheckInfoReplication() (PhysicalBridge.cs:575, ServerEndPoint.cs:1193). It writes INFO replication through bridge.TryWriteSync (ServerEndPoint.cs:1205). That message is an internal call, so TryPushToBacklog does not queue it (PhysicalBridge.cs:960), and WriteMessageTakingWriteLockSync blocks in _singleWriter.TryTakeSync() (PhysicalBridge.cs:927).

Both waits use SyncTimeout: the condition wait directly, and the write lock because the bridge is created with Multiplexer.TimeoutMilliseconds, which is SyncTimeout (ServerEndPoint.cs:1411, PhysicalBridge.cs:80). Whichever expires first decides how the stall ends:

  • If the condition wait expires first, the transaction switches to UNWATCH, writes it and releases the lock, and the reader goes on. The caller gets RedisTimeoutException on UNWATCH, with about SyncTimeout elapsed, which is the condition wait itself.
  • If the reader's wait expires first, INFO replication is dropped as timed out before write. The reader goes on, delivers the condition replies, and the transaction completes about SyncTimeout late, without an exception.

The repro counts both outcomes as stalls: rounds that took longer than half the timeout.

Exception

From a run with 5000 ms timeouts on 3.4.31 (client name removed):

Timeout awaiting response (outbound=0KiB, inbound=0KiB, 5009ms elapsed, timeout is 5000ms), command=UNWATCH, active: ECHO, next: WATCH repro:deaded0fecf944d9ac7c9f0b2675f37f, inst: 0, qu: 0, qs: 0, aw: True, bw: Inactive, rs: ComputeResult, ws: Writing, in: 18, in-pipe: 2001, last-in: 3, cur-in: 26, lm: 17/9329/9312/0, sync-ops: 0, async-ops: 1, serverEndpoint: 127.0.0.1:6379, conn-sec: 5,02, aoc: 1, mc: 1/1/0, IOCP: (Busy=0,Free=1000,Min=1,Max=1000), WORKER: (Busy=3,Free=32764,Min=16,Max=32767), POOL: (Threads=28,QueuedItems=0,CompletedItems=246367,Timers=17), v: 3.4.31.40215

How I read it:

  • active: ECHO and rs: ComputeResult are the reader inside the tracer's handler.
  • next: WATCH is the head of the written-awaiting-response queue: the transaction's first command.
  • conn-sec: 5,02 is the connection's age in a comma-decimal locale, about the timeout. The connection was opened just before the transaction.
  • ws is read when the exception is built, after the condition wait has expired. Some occurrences show ws: Idle instead of Writing. I take the stacks, not this field, as the evidence for who holds the lock.
  • I cannot account for qs: 0 while next is non-empty, or for exactly what the ~2 KB in in-pipe holds beyond the replies that have not been parsed.
  • Several occurrences on 3.4.31, and all of them on 3.4.5, read rs: DequeueResult, in-pipe: 18 with no active:. The 3.4.5 stall whose stacks are shown above had that snapshot (rs: DequeueResult, ws: Idle). So the snapshot alone does not show the reader inside the handler; the stacks do.

Repro

A standalone console program that references only StackExchange.Redis is at the end of this issue; other client versions were measured by changing the --version of the package. Each round creates a key, starts 16 tasks (parallel), and each task opens its own multiplexer and executes a transaction with Condition.HashExists and Condition.HashNotExists on that key. The concurrency matters: with one multiplexer per round I saw no stall at all (below), so it needs connects running at the same time. I don't know why.

docker run -d -p 6379:6379 redis:8.10.2-alpine
dotnet new console -n Repro && cd Repro
# replace Program.cs with the one at the end of this issue
dotnet add package StackExchange.Redis --version 3.4.31
dotnet run -c Release -- 127.0.0.1:6379 compare:fresh,async,control,ping,nocond 16 1000

Modes:

  • fresh: Connect, then the transaction at once.
  • async: ConnectAsync, then the transaction at once.
  • control: all 16 connects awaited first, then the 16 transactions.
  • ping: ConnectAsync, PingAsync, then the transaction.
  • nocond: ConnectAsync, then the same transaction without conditions.

Results, 1000 iterations each. Each cell: stalls (how many of them ended in RedisTimeoutException) / rounds per mode.

Client fresh async control ping nocond
3.4.31 11 (8) / 1000 13 (11) / 999 3 (1) / 1000 0 / 1000 0 / 1000
3.4.31, --min-threads=200 4 (2) / 1000 8 (6) / 999 1 (0) / 1000 0 / 1000 0 / 1000
3.4.29 4 (4) / 1000 13 (10) / 1000 0 / 999 1 (0) / 1000 0 / 1000
3.4.20 2 (2) / 1000 2 (2) / 1000 0 / 1000 0 / 1000 0 / 1000
3.4.5 3 (2) / 1000 6 (5) / 1000 1 (1) / 1000 0 / 1000 0 / 1000
2.12.1 10 (10) / 999 31 (26) / 999 5 (3) / 1000 0 / 1000 0 / 998

The two 3.4.31 rows come from the full compare 16 1000 (the second with --min-threads=200), which also runs a delay mode (ConnectAsync, 1 s delay, then the transaction): 3 (2) / 1000 without the flag and 0 / 1000 with it. Both of its timeouts without the flag had QueuedItems > 0 and WORKER Busy equal to Min, which is thread-pool starvation from load on the machine, not this deadlock.

Of the 93 timeouts in the table, 13 show pool starvation in their snapshot (QueuedItems > 0 or WORKER Busy at Min). The rest show QueuedItems=0 and WORKER Busy below Min. The ping row's one late completion on 3.4.29 had no exception and no snapshot, so I cannot tell what it was.

With one multiplexer per round (compare:fresh,async,ping,nocond 1 30000, plus a 2000-iteration run) there were 0 stalls in 32000 rounds of each mode on 3.4.31.

On 3.4.31, the fresh mode alone stopped at the first timeout at iteration 56, and on 3.4.5 at iteration 40.

Why the variants behave as they do:

  • ping avoids it by ordering. The PING reply is parsed by the same reader after it has left the ECHO handler, so by then INFO replication has already been written.
  • control only lowers the rate by timing. The last connect to return can still have its reader inside the handler when its transaction arrives a few milliseconds later, and the table shows that it does.
  • nocond: without conditions, GetMessages does not wait under the lock on a server that supports EXECABORT, as Redis 8 does, so nothing holds the lock while the reader needs it.

A reconnect test is also in the repro (reconnect 8 150): one long-lived multiplexer, 8 loops of conditional transactions, and CLIENT KILL TYPE normal SKIPME yes every 2 s. On 3.4.31, 1 of 150 kills was followed by the same UNWATCH timeout (rs: DequeueResult, ws: Writing, in-pipe: 25, WORKER Busy 3 of Min 16, QueuedItems 0). I did not capture stacks for that one, so I do not claim it is the same path. The call order allows it: StartBacklogProcessor (PhysicalBridge.cs:572) runs before CheckInfoReplication (PhysicalBridge.cs:575) in the same handler.

This affects code whose first command after a connect is a conditional transaction: service startup, or an integration-test fixture that opens a fresh multiplexer per test.

This is not #3292. That issue is about idle connections torn down by the dead-socket heuristic; here the connection is seconds old and is never torn down.

Expected

A command issued after Connect returns should not be able to deadlock with the handshake's own follow-up writes.

Possible directions (not tested): write INFO replication from the handshake without taking the lock synchronously on the reader, for example through the backlog or a fire-and-forget path; or complete the connect wait only after the INFO replication write has been issued.

Repro source

Program.cs
// Repro: a conditional transaction sent on a multiplexer whose connect has just returned can stall until the
// timeout and fail on UNWATCH.
//
// usage: dotnet run -c Release -- <endpoint> [mode=fresh] [parallel=16] [iterations=500] [--timeout=5000] [--min-threads=N]
//   fresh     each task calls Connect and immediately executes the conditional transaction; stops at the first timeout
//   async     like fresh, but each task connects with ConnectAsync
//   control   the same connects, all awaited first, then the same transactions
//   ping      ConnectAsync, then PingAsync, then the transaction
//   delay     ConnectAsync, then a 1 s delay, then the transaction
//   nocond    like async, but the transaction has no condition
//   compare   runs fresh, async, control, ping, delay and nocond in turn every iteration, counts timeouts and stalls
//             per mode and never stops early; compare:fresh,async runs only the listed modes
//   reconnect one long-lived multiplexer runs conditional transactions in a loop while its connections are killed
//             with CLIENT KILL TYPE normal; <iterations> is the number of kills
//
// A round that takes longer than half the timeout is a stall. It prints "STALL pid=<pid>" once, so a stack can be taken
// meanwhile. A stall ends either in a RedisTimeoutException on UNWATCH or in a transaction that completes late.

using System.Diagnostics;
using System.Reflection;
using StackExchange.Redis;

var positional = args.Where(a => !a.StartsWith("--")).ToArray();
var named = args.Where(a => a.StartsWith("--")).Select(a => a[2..].Split('=', 2)).ToDictionary(p => p[0], p => p.Length > 1 ? p[1] : "");

var endpoint = positional.Length > 0 ? positional[0] : "127.0.0.1:6379";
var mode = positional.Length > 1 ? positional[1] : "fresh";
var parallel = positional.Length > 2 ? int.Parse(positional[2]) : 16;
var iterations = positional.Length > 3 ? int.Parse(positional[3]) : 500;
var timeout = named.TryGetValue("timeout", out var t) ? int.Parse(t) : 5000;

if (named.TryGetValue("min-threads", out var minThreads))
{
    ThreadPool.SetMinThreads(int.Parse(minThreads), int.Parse(minThreads));
}

ThreadPool.GetMinThreads(out var minWorker, out var minIo);
var library = typeof(ConnectionMultiplexer).Assembly
    .GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion;
Console.WriteLine($"StackExchange.Redis {library}, runtime {Environment.Version}, {Environment.OSVersion}, {Environment.ProcessorCount} logical CPUs");
Console.WriteLine($"endpoint {endpoint}, mode {mode}, parallel {parallel}, timeout {timeout} ms, min threads {minWorker}/{minIo}, pid {Environment.ProcessId}");

using var admin = ConnectionMultiplexer.Connect(Options(allowAdmin: true));
var adminDb = admin.GetDatabase();
var clock = Stopwatch.StartNew();

if (mode == "reconnect")
{
    return await ReconnectAsync();
}

string[] modes = mode switch
{
    "compare" => ["fresh", "async", "control", "ping", "delay", "nocond"],
    _ when mode.StartsWith("compare:") => mode["compare:".Length..].Split(','),
    _ => [mode],
};
var compare = mode.StartsWith("compare");
var stalls = modes.ToDictionary(m => m, _ => 0);
var wrong = modes.ToDictionary(m => m, _ => 0);
var failures = modes.ToDictionary(m => m, _ => 0);
var rounds = modes.ToDictionary(m => m, _ => 0);

for (var iteration = 1; iteration <= iterations; iteration++)
{
    foreach (var current in modes)
    {
        try
        {
            var elapsed = await RoundAsync(current);
            rounds[current]++;
            if (elapsed.TotalMilliseconds > timeout / 2.0)
            {
                stalls[current]++;
                Console.WriteLine($"{current}: stall at iteration {iteration}, completed after {elapsed.TotalMilliseconds:F0} ms");
            }
        }
        catch (RedisConnectionException ex)
        {
            // Opening thousands of short-lived connections can run a client machine out of local ports; such a
            // round is not counted, and the loop waits for the ports to be released
            Console.WriteLine($"{current}: connect failed at iteration {iteration} ({ex.Message.Split('.')[0]}), round not counted");
            await Task.Delay(TimeSpan.FromSeconds(60));
        }
        catch (WrongOutcomeException ex)
        {
            rounds[current]++;
            wrong[current]++;
            Console.WriteLine($"{current}: wrong outcome at iteration {iteration}: {ex.Message}");
        }
        catch (RedisTimeoutException ex)
        {
            rounds[current]++;
            failures[current]++;
            stalls[current]++;
            Console.WriteLine($"{current}: timeout at iteration {iteration} after {clock.Elapsed.TotalSeconds:F0} s");
            Console.WriteLine(ex.Message);
            if (!compare)
            {
                return 1;
            }
        }
    }

    if (iteration % 100 == 0)
    {
        Console.WriteLine($"iteration {iteration} ({clock.Elapsed.TotalSeconds:F0} s): " + Summary());
    }
}

Console.WriteLine($"done, {iterations} iterations ({clock.Elapsed.TotalSeconds:F0} s): " + Summary());
return failures.Values.Sum() == 0 ? 0 : 1;

string Summary() => string.Join(", ", modes.Select(m => $"{m} {stalls[m]} stalls ({failures[m]} timeouts), {wrong[m]} wrong outcomes in {rounds[m]} rounds"));

ConfigurationOptions Options(bool allowAdmin = false)
{
    var options = ConfigurationOptions.Parse(endpoint);
    options.SyncTimeout = timeout;
    options.AsyncTimeout = timeout;
    options.AllowAdmin = allowAdmin;
    return options;
}

async Task<RedisKey> NewKeyAsync()
{
    RedisKey key = $"repro:{Guid.NewGuid():N}";
    await adminDb.HashSetAsync(key, "state", "open");
    await adminDb.KeyExpireAsync(key, TimeSpan.FromMinutes(1));
    return key;
}

Task<bool> Transaction(IConnectionMultiplexer connection, RedisKey key, int i, bool conditional = true)
{
    var transaction = connection.GetDatabase().CreateTransaction();
    if (conditional)
    {
        transaction.AddCondition(Condition.HashExists(key, "state"));
        transaction.AddCondition(Condition.HashNotExists(key, "winner"));
    }
    _ = transaction.HashSetAsync(key, "winner", i);
    return transaction.ExecuteAsync();
}

async Task<TimeSpan> RoundAsync(string current)
{
    var key = await NewKeyAsync();
    var connections = new ConnectionMultiplexer?[parallel];
    var round = Stopwatch.StartNew();
    using var stallWatch = new Timer(_ => Console.WriteLine($"STALL pid={Environment.ProcessId} mode={current}"), null, timeout / 2, Timeout.Infinite);
    try
    {
        bool[] results;
        switch (current)
        {
            case "fresh":
                results = await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(() =>
                {
                    connections[i] = ConnectionMultiplexer.Connect(Options());
                    return Transaction(connections[i]!, key, i);
                })));
                break;
            case "async":
                results = await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(async () =>
                {
                    connections[i] = await ConnectionMultiplexer.ConnectAsync(Options());
                    return await Transaction(connections[i]!, key, i);
                })));
                break;
            case "control":
                await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(() =>
                {
                    connections[i] = ConnectionMultiplexer.Connect(Options());
                })));
                results = await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(() => Transaction(connections[i]!, key, i))));
                break;
            case "ping":
                results = await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(async () =>
                {
                    connections[i] = await ConnectionMultiplexer.ConnectAsync(Options());
                    await connections[i]!.GetDatabase().PingAsync();
                    return await Transaction(connections[i]!, key, i);
                })));
                break;
            case "delay":
                results = await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(async () =>
                {
                    connections[i] = await ConnectionMultiplexer.ConnectAsync(Options());
                    await Task.Delay(TimeSpan.FromSeconds(1));
                    return await Transaction(connections[i]!, key, i);
                })));
                break;
            case "nocond":
                results = await Task.WhenAll(Enumerable.Range(0, parallel).Select(i => Task.Run(async () =>
                {
                    connections[i] = await ConnectionMultiplexer.ConnectAsync(Options());
                    return await Transaction(connections[i]!, key, i, conditional: false);
                })));
                break;
            default:
                throw new ArgumentException($"Unknown mode: {current}");
        }

        var committed = results.Count(r => r);
        var expected = current == "nocond" ? parallel : 1;
        if (committed != expected)
        {
            var stored = await adminDb.HashGetAsync(key, "winner");
            throw new WrongOutcomeException($"{committed} of {parallel} reported committed, expected {expected}; winner field {(stored.IsNull ? "absent" : "present")}; round took {round.Elapsed.TotalMilliseconds:F0} ms");
        }

        // the delay mode waits 1 s by design
        return current == "delay" ? round.Elapsed - TimeSpan.FromSeconds(1) : round.Elapsed;
    }
    finally
    {
        foreach (var connection in connections)
        {
            connection?.Dispose();
        }
    }
}

async Task<int> ReconnectAsync()
{
    using var target = ConnectionMultiplexer.Connect(Options());
    var stop = new CancellationTokenSource();
    var timeouts = 0;
    var connectionErrors = 0;
    var committed = 0L;
    var killsWithTimeout = new HashSet<int>();
    var kill = 0;

    var loops = Enumerable.Range(0, parallel).Select(i => Task.Run(async () =>
    {
        while (!stop.IsCancellationRequested)
        {
            var key = await NewKeyAsync();
            try
            {
                if (await Transaction(target, key, i))
                {
                    Interlocked.Increment(ref committed);
                }
            }
            catch (RedisTimeoutException ex)
            {
                Interlocked.Increment(ref timeouts);
                lock (killsWithTimeout)
                {
                    killsWithTimeout.Add(Volatile.Read(ref kill));
                }
                Console.WriteLine($"timeout after kill {Volatile.Read(ref kill)}: {ex.Message}");
            }
            catch (RedisConnectionException)
            {
                Interlocked.Increment(ref connectionErrors);
            }
        }
    })).ToArray();

    var server = admin.GetServer(admin.GetEndPoints()[0]);
    for (kill = 1; kill <= iterations; kill++)
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
        await server.ExecuteAsync("CLIENT", "KILL", "TYPE", "normal", "SKIPME", "yes");
        await Task.Delay(TimeSpan.FromSeconds(1));
        if (kill % 50 == 0)
        {
            Console.WriteLine($"kill {kill}: {committed} committed, {timeouts} timeouts, {connectionErrors} connection errors");
        }
    }

    stop.Cancel();
    await Task.WhenAll(loops);
    Console.WriteLine($"done, {iterations} kills ({clock.Elapsed.TotalSeconds:F0} s): {committed} committed, {timeouts} timeouts after {killsWithTimeout.Count} distinct kills, {connectionErrors} connection errors");
    return timeouts == 0 ? 0 : 1;
}

sealed class WrongOutcomeException(string message) : Exception(message);

Activity

  1. mgravell commented on Nov 12, 2026

    @mgravell
    Member

    Thanks for the detailed report; I will investigate

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions