You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
CancellationToken has no effect until Command Timeout while the server doesn't answer the prepended DISCARD ALL #6662
When a pooled connection is returned to the pool, DISCARD ALL is prepended to its next command (NpgsqlConnector.cs#L2556). If the user's CancellationToken fires while that prepended response is still outstanding, PerformImmediateUserCancellation only records the request and returns, because ReadingPrependedMessagesMRE.Wait(0) is false (#5032/#5033). The deferred cancellation (#5191/#5774) runs only after the prepended responses have been read.
If the server is unresponsive (frozen, partitioned, overloaded, docker pause), those responses never arrive and the token has no effect. The command ends only at Command Timeout plus the time the (failing) cancel request takes. With default settings this affects the first command of every pooled checkout, so for a pooled application, user cancellation against an unresponsive server is in practice bounded only by Command Timeout.
Steps to reproduce
A TCP proxy sits in front of a real PostgreSQL. Once "frozen", it keeps accepting connections and keeps existing sockets open, but relays no bytes. The repro warms one pooled connection, returns it to the pool, freezes the proxy, then runs ExecuteScalarAsync("select 1", token) with the token canceled at 3 s. Defaults: Command Timeout=30, Timeout=15, Cancellation Timeout=2000.
Program.cs (net10.0, references Npgsql)
// Usage: repro <pgHost:port> "<extra connection string>" <cancelAfterSeconds|none>usingSystem.Diagnostics;usingSystem.Net;usingSystem.Net.Sockets;usingNpgsql;varupstream=args[0].Split(':');varextra=args[1];varcancelAfter=args[2];varfrozen=false;varlistener=newTcpListener(IPAddress.Loopback,0);listener.Start();varproxyPort=((IPEndPoint)listener.LocalEndpoint).Port;_=Task.Run(async()=>{while(true){varclient=awaitlistener.AcceptTcpClientAsync();_=Task.Run(async()=>{usingvarc=client;usingvarserver=newTcpClient();awaitserver.ConnectAsync(upstream[0],int.Parse(upstream[1]));awaitTask.WhenAny(Pump(c.GetStream(),server.GetStream()),Pump(server.GetStream(),c.GetStream()));});}});asyncTaskPump(NetworkStreamfrom,NetworkStreamto){varbuf=newbyte[16384];while(true){intn;try{n=awaitfrom.ReadAsync(buf);}catch{return;}if(n==0)return;while(Volatile.Read(reffrozen))awaitTask.Delay(50);// frozen: hold the bytes, keep the socket opentry{awaitto.WriteAsync(buf.AsMemory(0,n));}catch{return;}}}varcs=$"Host=127.0.0.1;Port={proxyPort};Username=postgres;Password=pw;Database=postgres;Maximum Pool Size=1;{extra}";awaitusingvards=NpgsqlDataSource.Create(cs);// Warm one pooled connector and return it to the pool: DISCARD ALL is prepended to its next commandawaitusing(varwarm=awaitds.OpenConnectionAsync())awaitnewNpgsqlCommand("select 1",warm).ExecuteScalarAsync();awaitusingvarconn=awaitds.OpenConnectionAsync();// from the pool, no I/OVolatile.Write(reffrozen,true);varsw=Stopwatch.StartNew();usingvarcts=cancelAfter=="none"?newCancellationTokenSource():newCancellationTokenSource(TimeSpan.FromSeconds(double.Parse(cancelAfter)));try{awaitusingvarcmd=newNpgsqlCommand("select 1",conn);awaitcmd.ExecuteScalarAsync(cts.Token);Console.WriteLine($"completed after {sw.Elapsed.TotalSeconds:0.0}s");}catch(Exceptione){Console.WriteLine($"{e.GetType().Name} after {sw.Elapsed.TotalSeconds:0.0}s: {e.Message} / {e.InnerException?.GetType().Name}");}Console.WriteLine($"conn.FullState={conn.FullState}");
GSS Encryption Mode=Disable;Timeout=5;No Reset On Close=true
8.0 s
The last two rows differ only in the prepended DISCARD ALL. (The extra Timeout on the defaults row comes from the cancel request's GSS fallback; see #6655, I'll comment there separately.)
Root cause
PerformImmediateUserCancellation returns early on if (!ReadingPrependedMessagesMRE.Wait(0)), so no client-side deadline is armed.
When the user cancellation is deferred, arm the client-side deadline straight away: ReadBuffer.Timeout = CancellationTimeout; ReadBuffer.Cts.CancelAfter(CancellationTimeout), or cancel immediately when Cancellation Timeout is -1. If that read deadline fires while the deferred user cancellation is pending and the prepended responses are still unread, the read buffer does not send a PostgreSQL cancel request. It breaks the connector with OperationCanceledException. If the prepended responses do arrive in time, PerformDelayedUserCancellation proceeds as it does today and re-arms its own Cancellation Timeout.
Nothing blocks, so #5032 cannot come back, and no cancel request can reach a prepended query, so #4906 cannot either. With this change the defaults row drops from 60 s to 5.0 s (3 s plus the 2 s Cancellation Timeout). The Command Timeout path without a token is unchanged. I'll open a PR with a PgPostmasterMock test.
Activity