From 23e45b89a52d1c202f32dd50e8ad1e44c12c2865 Mon Sep 17 00:00:00 2001
From: Jelte Fennema-Nio <postgres@jeltef.nl>
Date: Sun, 19 Oct 2025 00:30:48 +0200
Subject: [PATCH v1] Add read-only disconnect_requested GUC for graceful client
 disconnect

This commit introduces a new read-only disconnect_requested GUC (similar
to in_hot_standby) that the server can turn on to politely request the
client to disconnect/reconnect when convenient. Because the GUC is
flagged GUC_REPORT, its new value is reported to the client in a
ParameterStatus message whenever it changes. This request is advisory
only: the connection remains fully functional and clients may continue
executing queries and starting new transactions. "When convenient" is
obviously not very well defined, but the primary targets are clients
that maintain a connection pool. Such clients should
disconnect/reconnect a connection in the pool when there's no user of
that connection. This is similar to how such clients often currently
remove a connection from the pool after the connection hits a maximum
lifetime of e.g. 1 hour[1][2]. Since psql does not have such a
connection pool all it does is tell the user when the GUC changes, so
that the user might choose a "convenient" time themselves.

The GUC defaults to "off" and is set to "on" by the server in two
situations:
1. Using a new pg_request_disconnect_backend(pid) function the GUC can be
   set to "on" for a specific backend using a procsignal.
2. During "smart" shutdown the GUC is set to "on" for all backends.

The pg_request_disconnect_backend() function is effectively a graceful
counterpart to pg_terminate_backend(). This function can be useful to
trigger a graceful reconnect of a specific backend. This can for
instance be used if the backend is using a lot of memory, and an
operator wants the client to reconnect to clear its catalog caches
but without causing any queries to fail.

The "smart" shutdown mode (aka sending SIGTERM to postmaster) is in
practice usually the *least smart* shutdown mode that a user can use. A
single client can indefinitely keep the server from actually shutting
down, while any new connection attempts will be greeted with a FATAL
error. That's not the behavior you want for your primary in production.
The only usage for smart shutdown that currently makes sense is for a
graceful shutdown or switchover of a read replica. Setting
disconnect_requested to "on" during smart shutdown allows for
cooperating clients to disconnect earlier and thus for the
shutdown/switchover to complete quicker.

It can now even make sense to use "smart" shutdown for a primary too, as
long as it's done in combination with "fast" shutdown: If you first
trigger a "smart" shutdown and then trigger a "fast" shutdown after
5 seconds, then all the cooperating clients will have disconnected
gracefully before the fast shutdown would have killed them
mid-transaction. Gracefully recovering from a shutdown mid-transaction
is often non-trivial for an application. Clients might be receiving errors
for later reconnection attempts, but those are often much easier to
handle gracefully (a simple retry is all that's needed).

Finally, this new setting is also intended to be advertised by other
servers implementing the Postgres protocol. For instance proxies like
PgBouncer can send a ParameterStatus message that sets
disconnect_requested to "on" when they want their clients to disconnect
so they can do a rolling restart or switchover without any downtime.
Specifically PgBouncer would be sending this when an operator shuts it
down using SHUTDOWN WAIT_FOR_CLIENTS[3].

Two notes of suboptimal behavior:
1. Proxies using this to report their own shutdown will cause the value
   of the ParameterStatus to be different than the value that
   "SHOW disconnect_requested" reports, unless they also intercept those
   queries.
2. Right now the ParameterStatus is only sent at completion of a query,
   not while a connection is idle. It'd be better if a connection pool
   could check the current state of idle connections too, before handing
   them off again. I'll address this in a follow-up commit once it's
   agreed on that the general approach is sensible.

[1]: https://www.pgbouncer.org/config.html#server_lifetime
[2]: https://github.com/brettwooldridge/HikariCP (see maxLifetime setting)
[3]: https://www.pgbouncer.org/usage.html#shutdown
---
 doc/src/sgml/config.sgml                      |  27 ++++
 doc/src/sgml/func/func-admin.sgml             |  36 ++++++
 doc/src/sgml/libpq.sgml                       |   4 +-
 doc/src/sgml/protocol.sgml                    |   4 +-
 src/backend/postmaster/postmaster.c           |  27 ++++
 src/backend/storage/ipc/procsignal.c          |   3 +
 src/backend/storage/ipc/signalfuncs.c         | 119 ++++++++++++++++--
 src/backend/tcop/postgres.c                   |  13 ++
 src/backend/utils/init/globals.c              |   1 +
 src/backend/utils/misc/guc.c                  |  19 +++
 src/backend/utils/misc/guc_parameters.dat     |   8 ++
 src/backend/utils/misc/guc_tables.c           |   3 +-
 src/bin/psql/command.c                        |   6 +
 src/bin/psql/common.c                         |  23 ++++
 src/bin/psql/meson.build                      |   1 +
 src/bin/psql/settings.h                       |   3 +
 src/bin/psql/t/040_disconnect_request.pl      |  58 +++++++++
 src/include/catalog/pg_proc.dat               |   5 +
 src/include/miscadmin.h                       |   1 +
 src/include/storage/procsignal.h              |   4 +-
 src/include/tcop/tcopprot.h                   |   1 +
 src/include/utils/guc.h                       |   1 +
 .../modules/libpq_pipeline/libpq_pipeline.c   |  77 ++++++++++++
 23 files changed, 432 insertions(+), 12 deletions(-)
 create mode 100644 src/bin/psql/t/040_disconnect_request.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 0848c18d329..3fc6bbdee8b 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -12340,6 +12340,33 @@ dynamic_library_path = '/usr/local/lib/postgresql:$libdir'
       </listitem>
      </varlistentry>
 
+     <varlistentry id="guc-disconnect-requested" xreflabel="disconnect_requested">
+      <term><varname>disconnect_requested</varname> (<type>boolean</type>)
+      <indexterm>
+       <primary><varname>disconnect_requested</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        Reports whether the server has politely requested that the client close
+        this connection at a convenient moment.  It is turned on when the server
+        enters a smart shutdown (see <xref linkend="app-pg-ctl"/>), or when
+        <link linkend="functions-admin-signal-table"><function>pg_request_disconnect_backend</function></link>
+        is called for this backend.  The request is advisory: the connection
+        remains fully usable, and it is up to the client whether and when to act
+        on it.  This is primarily useful for connection poolers, which can close
+        and re-establish an idle connection to help a server drain its
+        connections quickly, for example during a switchover.
+       </para>
+       <para>
+        Because this parameter is flagged for reporting to the client, its value
+        is delivered with a <literal>ParameterStatus</literal> protocol message
+        whenever it changes (see <xref linkend="protocol-async"/>), so a client
+        can observe it without issuing a query specifically for that purpose.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="guc-effective-wal-level" xreflabel="effective_wal_level">
       <term><varname>effective_wal_level</varname> (<type>enum</type>)
       <indexterm>
diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index 0eae1c1f616..5aeed5ed595 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -163,6 +163,42 @@
        </para></entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_request_disconnect_backend</primary>
+        </indexterm>
+        <function>pg_request_disconnect_backend</function> ( <parameter>pid</parameter> <type>integer</type>, <parameter>timeout</parameter> <type>bigint</type> <literal>DEFAULT</literal> <literal>0</literal> )
+        <returnvalue>boolean</returnvalue>
+       </para>
+       <para>
+        Requests the backend process with the specified process ID to politely
+        ask its client to disconnect when convenient. The request is made by
+        turning on the backend's read-only
+        <xref linkend="guc-disconnect-requested"/> parameter, whose new value is
+        reported to the client with a <literal>ParameterStatus</literal> message
+        (see <xref linkend="protocol-async"/>). The connection remains fully
+        functional; honoring the request is up to the client. The same
+        permission rules as <function>pg_cancel_backend</function> apply.
+       </para>
+       <para>
+        If <parameter>timeout</parameter> is not specified or zero, this
+        function returns <literal>true</literal> whether the client actually
+        disconnects or not, indicating only that the request was successfully
+        sent to the backend.  If the <parameter>timeout</parameter> is
+        specified (in milliseconds) and greater than zero, the function waits
+        until the backend has exited. If it exits within the timeout, the
+        function returns <literal>true</literal>.  On timeout, a warning is
+        emitted and <literal>false</literal> is returned.
+       </para>
+       <para>
+        This is useful for gracefully draining connections from a server, for
+        example during a switchover. Clients that do not check
+        <literal>disconnect_requested</literal> will simply keep the connection
+        open as before.
+       </para></entry>
+      </row>
+
       <row>
        <entry role="func_table_entry"><para role="func_signature">
         <indexterm>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 7d3c3bb66d8..32e110c8329 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -2905,6 +2905,7 @@ const char *PQparameterStatus(const PGconn *conn, const char *paramName);
         <member><varname>client_encoding</varname></member>
         <member><varname>DateStyle</varname></member>
         <member><varname>default_transaction_read_only</varname></member>
+        <member><varname>disconnect_requested</varname></member>
         <member><varname>in_hot_standby</varname></member>
         <member><varname>integer_datetimes</varname></member>
         <member><varname>IntervalStyle</varname></member>
@@ -2921,7 +2922,8 @@ const char *PQparameterStatus(const PGconn *conn, const char *paramName);
        <varname>in_hot_standby</varname> were not reported by releases before
        14; <varname>scram_iterations</varname> was not reported by releases
        before 16; <varname>search_path</varname> was not reported by releases
-       before 18.)
+       before 18; <varname>disconnect_requested</varname> was not reported by
+       releases before 20.)
        Note that
        <varname>server_version</varname> and
        <varname>server_encoding</varname>
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index 49f81676712..d42620967ae 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1542,6 +1542,7 @@ SELCT 1/0;<!-- this typo is intentional -->
      <member><varname>client_encoding</varname></member>
      <member><varname>DateStyle</varname></member>
      <member><varname>default_transaction_read_only</varname></member>
+     <member><varname>disconnect_requested</varname></member>
      <member><varname>in_hot_standby</varname></member>
      <member><varname>integer_datetimes</varname></member>
      <member><varname>IntervalStyle</varname></member>
@@ -1558,7 +1559,8 @@ SELCT 1/0;<!-- this typo is intentional -->
     <varname>in_hot_standby</varname> were not reported by releases before
     14; <varname>scram_iterations</varname> was not reported by releases
     before 16; <varname>search_path</varname> was not reported by releases
-    before 18.)
+    before 18; <varname>disconnect_requested</varname> was not reported by
+       releases before 20.)
     Note that
     <varname>server_version</varname>,
     <varname>server_encoding</varname> and
diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c
index 90c7c4528e8..e6522004ca9 100644
--- a/src/backend/postmaster/postmaster.c
+++ b/src/backend/postmaster/postmaster.c
@@ -2152,7 +2152,34 @@ process_pm_shutdown_request(void)
 			 * later state, do not change it.
 			 */
 			if (pmState == PM_RUN || pmState == PM_HOT_STANDBY)
+			{
+				dlist_iter	iter;
+
 				connsAllowed = false;
+
+				/*
+				 * Signal all backends to politely request that their clients
+				 * disconnect gracefully.  Each signaled backend reports this to
+				 * its client by turning on its disconnect_requested GUC.
+				 */
+				dlist_foreach(iter, &ActiveChildList)
+				{
+					PMChild    *bp = dlist_container(PMChild, elem, iter.cur);
+
+					/*
+					 * Only signal regular backends, since those are the ones
+					 * that have a client to notify.  Follow the same pattern as
+					 * SignalChildren to correctly distinguish backends from WAL
+					 * senders.
+					 */
+					if (bp->bkend_type == B_BACKEND &&
+						!IsPostmasterChildWalSender(bp->child_slot))
+					{
+						SendProcSignal(bp->pid, PROCSIG_REQUEST_DISCONNECT,
+									   INVALID_PROC_NUMBER);
+					}
+				}
+			}
 			else if (pmState == PM_STARTUP || pmState == PM_RECOVERY)
 			{
 				/* There should be no clients, so proceed to stop children */
diff --git a/src/backend/storage/ipc/procsignal.c b/src/backend/storage/ipc/procsignal.c
index 21a77f98c1d..62a3cb4dd39 100644
--- a/src/backend/storage/ipc/procsignal.c
+++ b/src/backend/storage/ipc/procsignal.c
@@ -713,6 +713,9 @@ procsignal_sigusr1_handler(SIGNAL_ARGS)
 	if (CheckProcSignal(PROCSIG_LOG_MEMORY_CONTEXT))
 		HandleLogMemoryContextInterrupt();
 
+	if (CheckProcSignal(PROCSIG_REQUEST_DISCONNECT))
+		HandleDisconnectRequestInterrupt();
+
 	if (CheckProcSignal(PROCSIG_PARALLEL_APPLY_MESSAGE))
 		HandleParallelApplyMessageInterrupt();
 
diff --git a/src/backend/storage/ipc/signalfuncs.c b/src/backend/storage/ipc/signalfuncs.c
index 800b699de21..f7722d04fd6 100644
--- a/src/backend/storage/ipc/signalfuncs.c
+++ b/src/backend/storage/ipc/signalfuncs.c
@@ -23,15 +23,17 @@
 #include "storage/pmsignal.h"
 #include "storage/proc.h"
 #include "storage/procarray.h"
+#include "storage/procsignal.h"
 #include "utils/acl.h"
 #include "utils/fmgrprotos.h"
 #include "utils/wait_event.h"
 
 
 /*
- * Send a signal to another backend.
+ * Check whether the current user has permission to signal the backend with
+ * the given PID.
  *
- * The signal is delivered if the user is either a superuser or the same
+ * Sending a signal is allowed if the user is either a superuser or the same
  * role as the backend being signaled. For "dangerous" signals, an explicit
  * check for superuser needs to be done prior to calling this function.
  *
@@ -42,6 +44,10 @@
  * In the event of a general failure (return code 1), a warning message will
  * be emitted. For permission errors, doing that is the responsibility of
  * the caller.
+ *
+ * The caller can optionally obtain a pointer to the backend's PGPROC struct
+ * using the proc_return argument.  If the caller doesn't need that, it can
+ * pass NULL.
  */
 #define SIGNAL_BACKEND_SUCCESS 0
 #define SIGNAL_BACKEND_ERROR 1
@@ -49,17 +55,21 @@
 #define SIGNAL_BACKEND_NOSUPERUSER 3
 #define SIGNAL_BACKEND_NOAUTOVAC 4
 static int
-pg_signal_backend(int pid, int sig)
+pg_check_signal_backend(int pid, PGPROC **proc_return)
 {
 	PGPROC	   *proc = BackendPidGetProc(pid);
 
+	if (proc_return != NULL)
+		*proc_return = proc;
+
 	/*
 	 * BackendPidGetProc returns NULL if the pid isn't valid; but by the time
-	 * we reach kill(), a process for which we get a valid proc here might
-	 * have terminated on its own.  There's no way to acquire a lock on an
-	 * arbitrary process to prevent that. But since so far all the callers of
-	 * this mechanism involve some request for ending the process anyway, that
-	 * it might end on its own first is not a problem.
+	 * the caller actually tries to send a signal, a process for which we get
+	 * a valid proc here might have terminated on its own.  There's no way to
+	 * acquire a lock on an arbitrary process to prevent that. But since so
+	 * far all the callers of this mechanism involve some request for ending
+	 * the process anyway, that it might end on its own first is not a
+	 * problem.
 	 *
 	 * Note that proc will also be NULL if the pid refers to an auxiliary
 	 * process or the postmaster (neither of which can be signaled via
@@ -100,6 +110,22 @@ pg_signal_backend(int pid, int sig)
 			 !has_privs_of_role(GetUserId(), ROLE_PG_SIGNAL_BACKEND))
 		return SIGNAL_BACKEND_NOPERMISSION;
 
+	return SIGNAL_BACKEND_SUCCESS;
+}
+
+/*
+ * Send a signal to another backend, after checking permissions.
+ *
+ * See pg_check_signal_backend for return codes.
+ */
+static int
+pg_signal_backend(int pid, int sig)
+{
+	int			r = pg_check_signal_backend(pid, NULL);
+
+	if (r != SIGNAL_BACKEND_SUCCESS)
+		return r;
+
 	/*
 	 * Can the process we just validated above end, followed by the pid being
 	 * recycled for a new process, before reaching here?  Then we'd be trying
@@ -276,6 +302,83 @@ pg_terminate_backend(PG_FUNCTION_ARGS)
 		PG_RETURN_BOOL(r == SIGNAL_BACKEND_SUCCESS);
 }
 
+/*
+ * Request a backend to ask its client to disconnect gracefully, by turning on
+ * the backend's disconnect_requested GUC.  That GUC is reported to the client
+ * via a ParameterStatus message; honoring it is advisory, so the connection
+ * remains fully functional.
+ *
+ * If timeout is 0, returns true as soon as the signal is sent successfully.
+ * If timeout is nonzero, waits up to that many milliseconds for the backend
+ * to exit (i.e. for the client to honor the request and disconnect). Returns
+ * true if the backend exits within the timeout, false (with a warning) if not.
+ *
+ * Permission checking follows the same rules as pg_cancel_backend.
+ */
+Datum
+pg_request_disconnect_backend(PG_FUNCTION_ARGS)
+{
+	int			pid;
+	int			r;
+	int64		timeout;		/* milliseconds */
+	PGPROC	   *proc;
+
+	pid = PG_GETARG_INT32(0);
+	timeout = PG_GETARG_INT64(1);
+
+	if (timeout < 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE),
+				 errmsg("\"timeout\" must not be negative")));
+
+	r = pg_check_signal_backend(pid, &proc);
+
+	if (r == SIGNAL_BACKEND_NOSUPERUSER)
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("permission denied to request backend disconnect"),
+				 errdetail("Only roles with the %s attribute may request disconnect from backends of roles with the %s attribute.",
+						   "SUPERUSER", "SUPERUSER")));
+
+	if (r == SIGNAL_BACKEND_NOAUTOVAC)
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("permission denied to request backend disconnect"),
+				 errdetail("Only roles with privileges of the \"%s\" role may request disconnect from autovacuum workers.",
+						   "pg_signal_autovacuum_worker")));
+
+	if (r == SIGNAL_BACKEND_NOPERMISSION)
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("permission denied to request backend disconnect"),
+				 errdetail("Only roles with privileges of the role whose backend is being signaled or with privileges of the \"%s\" role may request disconnect from this backend.",
+						   "pg_signal_backend")));
+
+	if (r != SIGNAL_BACKEND_SUCCESS)
+		PG_RETURN_BOOL(false);
+
+	if (proc->backendType != B_BACKEND)
+	{
+		ereport(WARNING,
+				(errmsg("PID %d is not a regular backend process", pid)));
+		PG_RETURN_BOOL(false);
+	}
+
+	if (SendProcSignal(pid, PROCSIG_REQUEST_DISCONNECT,
+					   INVALID_PROC_NUMBER) != 0)
+	{
+		ereport(WARNING,
+				(errmsg("could not send disconnect request to process %d: %m", pid)));
+		PG_RETURN_BOOL(false);
+	}
+
+	/* Wait only if actually requested */
+	if (timeout > 0)
+		PG_RETURN_BOOL(pg_wait_until_termination(pid, timeout));
+
+	PG_RETURN_BOOL(true);
+}
+
 /*
  * Signal to reload the database configuration
  *
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b6bdfe213fe..c812cb43582 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -3180,6 +3180,19 @@ FloatExceptionHandler(SIGNAL_ARGS)
 					   "invalid operation, such as division by zero.")));
 }
 
+/*
+ * Note that we've been asked to request that our client disconnect.  We act on
+ * it the next time we're ready for a query; see ReportChangedGUCOptions().
+ * Runs in a SIGUSR1 handler.
+ */
+void
+HandleDisconnectRequestInterrupt(void)
+{
+	disconnect_requested = true;
+	InterruptPending = true;
+	/* latch will be set by procsignal_sigusr1_handler */
+}
+
 /*
  * Tell the next CHECK_FOR_INTERRUPTS() to process recovery conflicts.  Runs
  * in a SIGUSR1 handler.
diff --git a/src/backend/utils/init/globals.c b/src/backend/utils/init/globals.c
index bbd28d14d99..4ac00814730 100644
--- a/src/backend/utils/init/globals.c
+++ b/src/backend/utils/init/globals.c
@@ -40,6 +40,7 @@ volatile sig_atomic_t IdleSessionTimeoutPending = false;
 volatile sig_atomic_t ProcSignalBarrierPending = false;
 volatile sig_atomic_t LogMemoryContextPending = false;
 volatile sig_atomic_t IdleStatsUpdateTimeoutPending = false;
+volatile sig_atomic_t disconnect_requested = false;
 volatile uint32 InterruptHoldoffCount = 0;
 volatile uint32 QueryCancelHoldoffCount = 0;
 volatile uint32 CritSectionCount = 0;
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index 774bbc9be5f..e01e3968ec1 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -2475,6 +2475,15 @@ BeginReportingGUCOptions(void)
 		SetConfigOption("in_hot_standby", "true",
 						PGC_INTERNAL, PGC_S_OVERRIDE);
 
+	/*
+	 * Same hack for disconnect_requested: reflect a disconnect that was
+	 * already requested for this backend (e.g. a smart shutdown that began
+	 * before this backend reached its first ready-for-query).
+	 */
+	if (disconnect_requested)
+		SetConfigOption("disconnect_requested", "true",
+						PGC_INTERNAL, PGC_S_OVERRIDE);
+
 	/* Transmit initial values of interesting variables */
 	hash_seq_init(&status, guc_hashtab);
 	while ((hentry = (GUCHashEntry *) hash_seq_search(&status)) != NULL)
@@ -2518,6 +2527,16 @@ ReportChangedGUCOptions(void)
 		SetConfigOption("in_hot_standby", "false",
 						PGC_INTERNAL, PGC_S_OVERRIDE);
 
+	/*
+	 * Likewise for disconnect_requested. The disconnect_requested
+	 * sig_atomic_t variable is only set from a signal handler, so we need to
+	 * reflect its value in the GUC if it changed. For speed, we rely on the
+	 * assumption that it can never transition from true to false.
+	 */
+	if (!disconnect_requested_guc && disconnect_requested)
+		SetConfigOption("disconnect_requested", "true",
+						PGC_INTERNAL, PGC_S_OVERRIDE);
+
 	/* Transmit new values of interesting variables */
 	slist_foreach_modify(iter, &guc_report_list)
 	{
diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat
index d421cdbde76..abc369687a1 100644
--- a/src/backend/utils/misc/guc_parameters.dat
+++ b/src/backend/utils/misc/guc_parameters.dat
@@ -819,6 +819,14 @@
   check_hook => 'check_default_with_oids',
 },
 
+{ name => 'disconnect_requested', type => 'bool', context => 'PGC_INTERNAL', group => 'PRESET_OPTIONS',
+  short_desc => 'Shows whether the server has requested this session to disconnect.',
+  long_desc => 'When true, the server has politely requested that the client close this connection at a convenient moment, for example during a smart shutdown or after pg_request_disconnect_backend() was called for this backend. The request is advisory: the connection remains fully usable.',
+  flags => 'GUC_REPORT | GUC_NOT_IN_SAMPLE | GUC_DISALLOW_IN_FILE',
+  variable => 'disconnect_requested_guc',
+  boot_val => 'false',
+},
+
 { name => 'dynamic_library_path', type => 'string', context => 'PGC_SUSET', group => 'CLIENT_CONN_OTHER',
   short_desc => 'Sets the path for dynamically loadable modules.',
   long_desc => 'If a dynamically loadable module needs to be opened and the specified name does not have a directory component (i.e., the name does not contain a slash), the system will search this path for the specified file.',
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 90aa374b3ec..c68bad9e11d 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -673,8 +673,9 @@ static char *recovery_target_lsn_string;
 /* should be static, but commands/variable.c needs to get at this */
 char	   *role_string;
 
-/* should be static, but guc.c needs to get at this */
+/* both of these should be static, but guc.c needs to get at them */
 bool		in_hot_standby_guc;
+bool		disconnect_requested_guc;
 
 /*
  * set default log_min_messages to WARNING for all process types
diff --git a/src/bin/psql/command.c b/src/bin/psql/command.c
index 7fdd4511a36..de977e28524 100644
--- a/src/bin/psql/command.c
+++ b/src/bin/psql/command.c
@@ -4615,6 +4615,12 @@ SyncVariables(void)
 	/* send stuff to it, too */
 	PQsetErrorVerbosity(pset.db, pset.verbosity);
 	PQsetErrorContextVisibility(pset.db, pset.show_context);
+
+	/*
+	 * We should report a disconnect request for this connection when one
+	 * arrives, so clear the flag.
+	 */
+	pset.disconnect_request_reported = false;
 }
 
 /*
diff --git a/src/bin/psql/common.c b/src/bin/psql/common.c
index 7db8025d24c..de1100acf89 100644
--- a/src/bin/psql/common.c
+++ b/src/bin/psql/common.c
@@ -735,6 +735,27 @@ PSQLexecWatch(const char *query, const printQueryOpt *opt, FILE *printQueryFout,
 }
 
 
+/*
+ * ReportGracefulDisconnect: if the server has asked us to disconnect, say so
+ *
+ * The server advertises the disconnect_requested GUC via ParameterStatus when
+ * it wants us to disconnect gracefully (e.g. during a smart shutdown).  It's
+ * advisory, so we just tell the user about it once per connection.
+ */
+static void
+ReportGracefulDisconnect(void)
+{
+	const char *disconnect_requested;
+
+	disconnect_requested = PQparameterStatus(pset.db, "disconnect_requested");
+	if (!pset.disconnect_request_reported && disconnect_requested &&
+		strcmp(disconnect_requested, "on") == 0)
+	{
+		pg_log_info("Server requested graceful disconnect when convenient.");
+		pset.disconnect_request_reported = true;
+	}
+}
+
 /*
  * PrintNotifications: check for asynchronous notifications, and print them out
  */
@@ -757,6 +778,8 @@ PrintNotifications(void)
 		PQfreemem(notify);
 		PQconsumeInput(pset.db);
 	}
+
+	ReportGracefulDisconnect();
 }
 
 
diff --git a/src/bin/psql/meson.build b/src/bin/psql/meson.build
index 922b2845267..3cf21556de8 100644
--- a/src/bin/psql/meson.build
+++ b/src/bin/psql/meson.build
@@ -78,6 +78,7 @@ tests += {
       't/010_tab_completion.pl',
       't/020_cancel.pl',
       't/030_pager.pl',
+      't/040_disconnect_request.pl',
     ],
   },
 }
diff --git a/src/bin/psql/settings.h b/src/bin/psql/settings.h
index a3d088d80cd..61e973659af 100644
--- a/src/bin/psql/settings.h
+++ b/src/bin/psql/settings.h
@@ -139,6 +139,9 @@ typedef struct _psqlSettings
 								 * loop */
 	bool		cur_cmd_interactive;
 	int			sversion;		/* backend server version */
+	bool		disconnect_request_reported;	/* have we told the user the
+												 * server requested a graceful
+												 * disconnect? */
 	const char *progname;		/* in case you renamed psql */
 	char	   *inputfile;		/* file being currently processed, if any */
 	uint64		lineno;			/* also for error reporting */
diff --git a/src/bin/psql/t/040_disconnect_request.pl b/src/bin/psql/t/040_disconnect_request.pl
new file mode 100644
index 00000000000..2800e22d801
--- /dev/null
+++ b/src/bin/psql/t/040_disconnect_request.pl
@@ -0,0 +1,58 @@
+
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+use Time::HiRes qw(usleep);
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->start;
+
+my $psql = $node->background_psql('postgres');
+
+# Confirm connection works and that disconnect has not been requested yet.
+my $result = $psql->query_safe("SELECT 'before_shutdown'");
+like($result, qr/before_shutdown/, 'connection works before smart shutdown');
+is($psql->query_safe("SHOW disconnect_requested"),
+	'off', 'disconnect_requested is off before smart shutdown');
+
+# Initiate smart shutdown without waiting for it to complete
+$node->command_ok(
+	[ 'pg_ctl', 'stop', '-D', $node->data_dir, '-m', 'smart', '--no-wait' ],
+	'pg_ctl smart shutdown');
+
+# Once the backend processes the smart shutdown signal it turns on the
+# disconnect_requested GUC, which is reported to the client through a
+# ParameterStatus message.  psql logs a notice the first time it observes this.
+# Poll with queries until psql reports it.
+my $saw_request = 0;
+for (my $i = 0; $i < 100; $i++)
+{
+	my $out = $psql->query("SELECT 'after_shutdown'");
+	if ($psql->{stderr} =~
+		/Server requested graceful disconnect when convenient/)
+	{
+		$saw_request = 1;
+		# The query should still have succeeded
+		like($out, qr/after_shutdown/,
+			'query still works after disconnect was requested');
+		last;
+	}
+	usleep(50_000);
+}
+ok($saw_request,
+	'psql reported graceful disconnect request during smart shutdown');
+
+# The GUC is now reported as on for this session too.  Use query() rather than
+# query_safe(), since psql may emit the advisory notice again on stderr.
+like($psql->query("SHOW disconnect_requested"),
+	qr/^on$/m, 'disconnect_requested is on after smart shutdown');
+
+$psql->quit;
+
+done_testing();
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 3cb84359adf..a25179bb7e0 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -6792,6 +6792,11 @@
   proname => 'pg_terminate_backend', provolatile => 'v', prorettype => 'bool',
   proargtypes => 'int4 int8', proargnames => '{pid,timeout}',
   proargdefaults => '{0}', prosrc => 'pg_terminate_backend' },
+{ oid => '9018', descr => 'request a server process to ask its client to disconnect',
+  proname => 'pg_request_disconnect_backend', provolatile => 'v', prorettype => 'bool',
+  proargtypes => 'int4 int8', proargnames => '{pid,timeout}',
+  proargdefaults => '{0}',
+  prosrc => 'pg_request_disconnect_backend' },
 { oid => '2172', descr => 'prepare for taking an online backup',
   proname => 'pg_backup_start', provolatile => 'v', proparallel => 'r',
   prorettype => 'pg_lsn', proargtypes => 'text bool',
diff --git a/src/include/miscadmin.h b/src/include/miscadmin.h
index 7170a4bff98..3c5bfc3596e 100644
--- a/src/include/miscadmin.h
+++ b/src/include/miscadmin.h
@@ -101,6 +101,7 @@ extern PGDLLIMPORT volatile sig_atomic_t IdleStatsUpdateTimeoutPending;
 
 extern PGDLLIMPORT volatile sig_atomic_t CheckClientConnectionPending;
 extern PGDLLIMPORT volatile sig_atomic_t ClientConnectionLost;
+extern PGDLLIMPORT volatile sig_atomic_t disconnect_requested;
 
 /* these are marked volatile because they are examined by signal handlers: */
 extern PGDLLIMPORT volatile uint32 InterruptHoldoffCount;
diff --git a/src/include/storage/procsignal.h b/src/include/storage/procsignal.h
index aaa158bfd66..b959052110d 100644
--- a/src/include/storage/procsignal.h
+++ b/src/include/storage/procsignal.h
@@ -41,9 +41,11 @@ typedef enum
 	PROCSIG_RECOVERY_CONFLICT,	/* backend is blocking recovery, check
 								 * PGPROC->pendingRecoveryConflicts for the
 								 * reason */
+	PROCSIG_REQUEST_DISCONNECT, /* ask backend to request its client to
+								 * disconnect */
 } ProcSignalReason;
 
-#define NUM_PROCSIGNALS (PROCSIG_RECOVERY_CONFLICT + 1)
+#define NUM_PROCSIGNALS (PROCSIG_REQUEST_DISCONNECT + 1)
 
 typedef enum
 {
diff --git a/src/include/tcop/tcopprot.h b/src/include/tcop/tcopprot.h
index 5bc5bcfb20d..c2155f7b104 100644
--- a/src/include/tcop/tcopprot.h
+++ b/src/include/tcop/tcopprot.h
@@ -74,6 +74,7 @@ extern void die(SIGNAL_ARGS);
 pg_noreturn extern void quickdie(SIGNAL_ARGS);
 extern void StatementCancelHandler(SIGNAL_ARGS);
 pg_noreturn extern void FloatExceptionHandler(SIGNAL_ARGS);
+extern void HandleDisconnectRequestInterrupt(void);
 extern void HandleRecoveryConflictInterrupt(void);
 extern void ProcessClientReadInterrupt(bool blocked);
 extern void ProcessClientWriteInterrupt(bool blocked);
diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h
index 8057d7870ad..c9d42d61b42 100644
--- a/src/include/utils/guc.h
+++ b/src/include/utils/guc.h
@@ -325,6 +325,7 @@ extern PGDLLIMPORT int tcp_user_timeout;
 
 extern PGDLLIMPORT char *role_string;
 extern PGDLLIMPORT bool in_hot_standby_guc;
+extern PGDLLIMPORT bool disconnect_requested_guc;
 extern PGDLLIMPORT bool trace_sort;
 
 #ifdef DEBUG_BOUNDED_SORT
diff --git a/src/test/modules/libpq_pipeline/libpq_pipeline.c b/src/test/modules/libpq_pipeline/libpq_pipeline.c
index 1660a68955e..d3dd136fc26 100644
--- a/src/test/modules/libpq_pipeline/libpq_pipeline.c
+++ b/src/test/modules/libpq_pipeline/libpq_pipeline.c
@@ -2101,6 +2101,80 @@ process_result(PGconn *conn, PGresult *res, int results, int numsent)
 }
 
 
+static void
+test_disconnect_request(PGconn *conn)
+{
+	PGconn	   *otherConn;
+	PGresult   *res;
+	int			pid;
+	char		pid_str[32];
+	const char *val;
+	int			i;
+
+	fprintf(stderr, "test disconnect request... ");
+
+	otherConn = copy_connection(conn);
+	Assert(PQstatus(otherConn) == CONNECTION_OK);
+
+	if (!PQconsumeInput(conn))
+		pg_fatal("PQconsumeInput failed: %s", PQerrorMessage(conn));
+
+	pid = PQbackendPID(conn);
+	snprintf(pid_str, sizeof(pid_str), "%d", pid);
+
+	/* The server should not have requested a disconnect yet. */
+	val = PQparameterStatus(conn, "disconnect_requested");
+	if (val && strcmp(val, "on") == 0)
+		pg_fatal("disconnect already requested before pg_request_disconnect_backend");
+
+	/* Ask the target backend to request its client to disconnect. */
+	{
+		const char *paramValues[1] = {pid_str};
+
+		res = PQexecParams(otherConn,
+						   "SELECT pg_request_disconnect_backend($1)",
+						   1, NULL, paramValues,
+						   NULL, NULL, 0);
+	}
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pg_fatal("pg_request_disconnect_backend failed: %s", PQerrorMessage(otherConn));
+	if (strcmp(PQgetvalue(res, 0, 0), "t") != 0)
+		pg_fatal("pg_request_disconnect_backend returned false");
+	PQclear(res);
+
+	/*
+	 * The backend reflects the request in its disconnect_requested GUC the next
+	 * time it becomes ready for a query, and (thanks to GUC_REPORT) reports the
+	 * new value with a ParameterStatus message.  So run a query to give it that
+	 * opportunity; the connection remains fully usable meanwhile.  We loop in
+	 * case the asynchronous signal has not been delivered to the target backend
+	 * by the time it processes our first query.
+	 */
+	for (i = 0; i < 100; i++)
+	{
+		res = PQexec(conn, "SELECT 'still_alive'");
+		if (PQresultStatus(res) != PGRES_TUPLES_OK)
+			pg_fatal("query failed after disconnect request: %s", PQerrorMessage(conn));
+		if (strcmp(PQgetvalue(res, 0, 0), "still_alive") != 0)
+			pg_fatal("unexpected query result after disconnect request");
+		PQclear(res);
+
+		val = PQparameterStatus(conn, "disconnect_requested");
+		if (val && strcmp(val, "on") == 0)
+			break;
+
+		pg_usleep(50000);		/* 50ms */
+	}
+
+	val = PQparameterStatus(conn, "disconnect_requested");
+	if (!val || strcmp(val, "on") != 0)
+		pg_fatal("disconnect_requested not reported as on after pg_request_disconnect_backend");
+
+	PQfinish(otherConn);
+
+	fprintf(stderr, "ok\n");
+}
+
 static void
 usage(const char *progname)
 {
@@ -2118,6 +2192,7 @@ print_test_list(void)
 {
 	printf("cancel\n");
 	printf("disallowed_in_pipeline\n");
+	printf("disconnect_request\n");
 	printf("multi_pipelines\n");
 	printf("nosync\n");
 	printf("pipeline_abort\n");
@@ -2225,6 +2300,8 @@ main(int argc, char **argv)
 		test_cancel(conn);
 	else if (strcmp(testname, "disallowed_in_pipeline") == 0)
 		test_disallowed_in_pipeline(conn);
+	else if (strcmp(testname, "disconnect_request") == 0)
+		test_disconnect_request(conn);
 	else if (strcmp(testname, "multi_pipelines") == 0)
 		test_multi_pipelines(conn);
 	else if (strcmp(testname, "nosync") == 0)

base-commit: c71d43025d7aaf12fe461ec635bd2eda220c9f4a
-- 
2.54.0

