From 7295c719e3c0c392d5de1f4f54433cad7f6e75b2 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas.vondra@postgresql.org>
Date: Mon, 9 Nov 2026 00:26:55 +0100
Subject: [PATCH v1 1/2] Discard pending timeout cancel requests at end of
 statement.

If statement_timeout fires and the statement then completes without
reaching a CHECK_FOR_INTERRUPTS(), the timeout's cancel request is left
pending.  Before commit 22f6f2c1ccb (v13), disable_statement_timeout()
reset the timeout's indicator, so the request was ignored while reading
the next command.  Since then, it does nothing once the timeout has
fired, so the next command fails with a statement timeout without being
executed.

This is relatively easy to hit without special privileges, e.g. with
CALL or FETCH, which don't check for interrupts after evaluating a
long-running expression.  Of course, most systems either don't have the
statement_timeout set at all, or there's a significant margin.

To fix, make disable_statement_timeout() always fetch and reset the
indicator, and discard the pending request if it was set.  Likewise for
lock_timeout, which can leave a request behind if it fires just as the
lock is granted.  ProcessInterrupts() uses this pattern too.  This also
discards a user's cancel request arriving at about the same time, which
seems acceptable, as the statement has completed anyway.

Back-patch to all supported branches.

Author: Tomas Vondra <tomas@vondra.me>
Reviewed-by: FIXME
Discussion: FIXME
Backpatch-through: 15
---
 src/backend/tcop/postgres.c | 24 +++++++++++++++++++++++-
 1 file changed, 23 insertions(+), 1 deletion(-)

diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..6d301f8102b 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -5401,10 +5401,32 @@ enable_statement_timeout(void)
 
 /*
  * Disable statement timeout, if active.
+ *
+ * If the statement timeout has already fired, but the statement completed
+ * without reaching a CHECK_FOR_INTERRUPTS() afterwards, the query cancel
+ * request sent by the timeout is still pending.  Discard it, as otherwise
+ * it would cancel whatever we do next.  This also discards a cancel request
+ * from the user that arrived at about the same time, since we can't tell
+ * them apart, but that seems acceptable: the statement has completed anyway.
  */
 static void
 disable_statement_timeout(void)
 {
+	bool		lock_timeout_occurred;
+	bool		stmt_timeout_occurred;
+
+	/* Keep the indicator in case the timeout fires right now; see below */
 	if (get_timeout_active(STATEMENT_TIMEOUT))
-		disable_timeout(STATEMENT_TIMEOUT, false);
+		disable_timeout(STATEMENT_TIMEOUT, true);
+
+	/*
+	 * A lock timeout that fired just as the lock was granted can leave a
+	 * pending cancel request behind in the same way (see ProcSleep()). Always
+	 * fetch and reset both indicators, as in ProcessInterrupts().
+	 */
+	lock_timeout_occurred = get_timeout_indicator(LOCK_TIMEOUT, true);
+	stmt_timeout_occurred = get_timeout_indicator(STATEMENT_TIMEOUT, true);
+
+	if (lock_timeout_occurred || stmt_timeout_occurred)
+		QueryCancelPending = false;
 }
-- 
2.55.0

