From 94394e02ed1a542c2cdb20784d988ee7671e3907 Mon Sep 17 00:00:00 2001
From: Dave Cramer <davecramer@gmail.com>
Date: Fri, 5 Dec 2025 18:20:23 -0500
Subject: [PATCH v3 1/1] Add _pq_.cursor protocol extension for cursor options

Add a protocol extension, _pq_.cursor, that allows clients
to pass CURSOR_OPT_* flags in Bind messages, enabling HOLD,
SCROLL, and NO_SCROLL on named portals without bumping the
protocol version. The extension appends an optional Int32
field to the Bind message when negotiated during connection
startup.

Add PQsendBindWithCursorOptions() to libpq, which sends
Bind+Describe to create a named portal with cursor options.
Non-zero options require the extension; zero options always
succeed. The cursor_protocol connection parameter controls
negotiation.

Also, a new test module is added.
---
 doc/src/sgml/libpq.sgml                       |  63 +-
 doc/src/sgml/protocol.sgml                    |  31 +-
 src/backend/tcop/backend_startup.c            |  21 +-
 src/backend/tcop/postgres.c                   |   4 +
 src/include/libpq/libpq-be.h                  |   1 +
 src/interfaces/libpq/exports.txt              |   1 +
 src/interfaces/libpq/fe-connect.c             |  11 +
 src/interfaces/libpq/fe-exec.c                | 131 +++
 src/interfaces/libpq/fe-protocol3.c           |  14 +
 src/interfaces/libpq/libpq-fe.h               |   4 +
 src/interfaces/libpq/libpq-int.h              |   2 +
 src/test/modules/Makefile                     |   1 +
 .../modules/libpq_protocol_cursor/.gitignore  |   5 +
 .../modules/libpq_protocol_cursor/Makefile    |  25 +
 .../libpq_protocol_cursor.c                   | 799 ++++++++++++++++++
 .../modules/libpq_protocol_cursor/meson.build |  32 +
 .../t/001_libpq_protocol_cursor.pl            |  42 +
 src/test/modules/meson.build                  |   1 +
 18 files changed, 1177 insertions(+), 11 deletions(-)
 create mode 100644 src/test/modules/libpq_protocol_cursor/.gitignore
 create mode 100644 src/test/modules/libpq_protocol_cursor/Makefile
 create mode 100644 src/test/modules/libpq_protocol_cursor/libpq_protocol_cursor.c
 create mode 100644 src/test/modules/libpq_protocol_cursor/meson.build
 create mode 100644 src/test/modules/libpq_protocol_cursor/t/001_libpq_protocol_cursor.pl

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 6db823808fc..29a71cd7c06 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -5331,8 +5331,9 @@ unsigned char *PQunescapeBytea(const unsigned char *from, size_t *to_length);
    <xref linkend="libpq-PQsendQueryPrepared"/>,
    <xref linkend="libpq-PQsendDescribePrepared"/>,
    <xref linkend="libpq-PQsendDescribePortal"/>,
-   <xref linkend="libpq-PQsendClosePrepared"/>, and
-   <xref linkend="libpq-PQsendClosePortal"/>,
+   <xref linkend="libpq-PQsendClosePrepared"/>,
+   <xref linkend="libpq-PQsendClosePortal"/>, and
+   <xref linkend="libpq-PQsendBindWithCursorOptions"/>,
    which can be used with <xref linkend="libpq-PQgetResult"/> to duplicate
    the functionality of
    <xref linkend="libpq-PQexecParams"/>,
@@ -5530,6 +5531,58 @@ int PQsendClosePortal(PGconn *conn, const char *portalName);
      </listitem>
     </varlistentry>
 
+    <varlistentry id="libpq-PQsendBindWithCursorOptions">
+     <term><function>PQsendBindWithCursorOptions</function><indexterm><primary>PQsendBindWithCursorOptions</primary></indexterm></term>
+
+     <listitem>
+      <para>
+       Creates a named portal from a previously prepared statement, with
+       the specified cursor options applied.
+<synopsis>
+int PQsendBindWithCursorOptions(PGconn *conn,
+                                const char *stmtName,
+                                int nParams,
+                                const char *const *paramValues,
+                                const int *paramLengths,
+                                const int *paramFormats,
+                                int resultFormat,
+                                const char *portalName,
+                                int cursorOptions);
+</synopsis>
+      </para>
+
+      <para>
+       The <literal>cursorOptions</literal> parameter is a bitmask of
+       <symbol>CURSOR_OPT_*</symbol> flags defined in
+       <filename>src/include/nodes/parsenodes.h</filename>.
+       Note that <symbol>CURSOR_OPT_BINARY</symbol> has no effect here;
+       binary output is controlled by <literal>resultFormat</literal> on
+       the subsequent FETCH call instead.
+      </para>
+
+      <para>
+       The <literal>portalName</literal> must be a non-empty string;
+       unnamed portals are rejected.  The function sends a Bind message
+       to create the portal but does not execute it.  The portal can
+       later be operated on with cursor commands such as FETCH, MOVE,
+       and CLOSE.
+       Returns 1 on success, 0 on failure.
+      </para>
+
+      <para>
+       The <literal>_pq_.protocol_cursor</literal> protocol extension must have
+       been successfully negotiated during connection startup for cursor
+       options to take effect.  This is enabled by setting the
+       <literal>protocol_cursor</literal> connection parameter to
+       <literal>1</literal>.  If the extension was not negotiated and
+       <literal>cursorOptions</literal> is non-zero, the function
+       returns 0.  Passing <literal>cursorOptions</literal> as 0 is
+       always permitted and creates a named portal without any cursor
+       options, regardless of whether the extension was negotiated.
+      </para>
+     </listitem>
+    </varlistentry>
+
     <varlistentry id="libpq-PQgetResult">
      <term><function>PQgetResult</function><indexterm><primary>PQgetResult</primary></indexterm></term>
 
@@ -5544,6 +5597,7 @@ int PQsendClosePortal(PGconn *conn, const char *portalName);
        <xref linkend="libpq-PQsendDescribePortal"/>,
        <xref linkend="libpq-PQsendClosePrepared"/>,
        <xref linkend="libpq-PQsendClosePortal"/>,
+       <xref linkend="libpq-PQsendBindWithCursorOptions"/>,
        <xref linkend="libpq-PQsendPipelineSync"/>, or
        <xref linkend="libpq-PQpipelineSync"/>
        call, and returns it.
@@ -5920,8 +5974,9 @@ int PQflush(PGconn *conn);
      The functions <xref linkend="libpq-PQsendPrepare"/>,
      <xref linkend="libpq-PQsendDescribePrepared"/>,
      <xref linkend="libpq-PQsendDescribePortal"/>,
-     <xref linkend="libpq-PQsendClosePrepared"/>, and
-     <xref linkend="libpq-PQsendClosePortal"/> also work in pipeline mode.
+     <xref linkend="libpq-PQsendClosePrepared"/>,
+     <xref linkend="libpq-PQsendClosePortal"/>, and
+     <xref linkend="libpq-PQsendBindWithCursorOptions"/> also work in pipeline mode.
      Result processing is described below.
     </para>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index 49f81676712..07fd2b3b659 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -346,9 +346,14 @@
 
      <tbody>
       <row>
-       <entry namest="last" align="center" valign="middle">
-        <emphasis>(No supported protocol extensions are currently defined.)</emphasis>
-       </entry>
+      <entry><literal>_pq_.protocol_cursor</literal></entry>
+      <entry><literal>true</literal></entry>
+      <entry>PostgreSQL 19 and later</entry>
+      <entry>Enables cursor options in the Bind message.
+        When set to <literal>true</literal>, the Bind message may include
+        an optional cursor options field to control portal behavior.
+        See <xref linkend="protocol-message-formats-Bind"/> for details.
+      </entry>
       </row>
      </tbody>
     </tgroup>
@@ -1101,6 +1106,9 @@ SELCT 1/0;<!-- this typo is intentional -->
     pass NULL values for them in the Bind message.)
     Bind also specifies the format to use for any data returned
     by the query; the format can be specified overall, or per-column.
+    If the <literal>_pq_.protocol_cursor</literal> protocol option is enabled,
+    Bind can optionally include cursor options to control portal behavior,
+    such as creating scrollable or holdable cursors.
     The response is either BindComplete or ErrorResponse.
    </para>
 
@@ -4411,6 +4419,23 @@ psql "dbname=postgres replication=database" -c "IDENTIFY_SYSTEM;"
         </para>
        </listitem>
       </varlistentry>
+
+      <varlistentry>
+       <term>Int32 (optional)</term>
+       <listitem>
+        <para>
+         Cursor options, only present if <literal>_pq_.protocol_cursor</literal>
+         is enabled.  A bitmask of flags that control the behavior of
+         the portal being created.  The supported flags are defined as
+         <symbol>CURSOR_OPT_*</symbol> in
+         <filename>src/include/nodes/parsenodes.h</filename>.
+         Note that <symbol>CURSOR_OPT_BINARY</symbol> has no effect here;
+         binary output is controlled by the result format codes in the
+         Bind message itself.
+         If this field is not present, no cursor options are applied.
+        </para>
+       </listitem>
+      </varlistentry>
      </variablelist>
     </listitem>
    </varlistentry>
diff --git a/src/backend/tcop/backend_startup.c b/src/backend/tcop/backend_startup.c
index 5abf276c898..792d69515c0 100644
--- a/src/backend/tcop/backend_startup.c
+++ b/src/backend/tcop/backend_startup.c
@@ -779,11 +779,24 @@ ProcessStartupPacket(Port *port, bool ssl_done, bool gss_done)
 			{
 				/*
 				 * Any option beginning with _pq_. is reserved for use as a
-				 * protocol-level option, but at present no such options are
-				 * defined.
+				 * protocol-level option.
 				 */
-				unrecognized_protocol_options =
-					lappend(unrecognized_protocol_options, pstrdup(nameptr));
+				if (strcmp(nameptr, "_pq_.protocol_cursor") == 0)
+				{
+					/* Enable cursor options support via Bind message */
+					if (!parse_bool(valptr, &port->protocol_cursor_enabled))
+						ereport(FATAL,
+								(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+								 errmsg("invalid value for parameter \"%s\": \"%s\"",
+										"_pq_.protocol_cursor",
+										valptr)));
+				}
+				else
+				{
+					/* Unrecognized protocol option */
+					unrecognized_protocol_options =
+						lappend(unrecognized_protocol_options, pstrdup(nameptr));
+				}
 			}
 			else
 			{
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index b3563113219..4347f1d2ff9 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -2010,6 +2010,10 @@ exec_bind_message(StringInfo input_message)
 			rformats[i] = pq_getmsgint(input_message, 2);
 	}
 
+	/* Get cursor options if present (_pq_.protocol_cursor enabled) */
+	if (MyProcPort->protocol_cursor_enabled &&
+		input_message->cursor < input_message->len)
+		portal->cursorOptions = pq_getmsgint(input_message, 4);
 	pq_getmsgend(input_message);
 
 	/*
diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h
index 921b2daa4ff..8330f40f2b8 100644
--- a/src/include/libpq/libpq-be.h
+++ b/src/include/libpq/libpq-be.h
@@ -151,6 +151,7 @@ typedef struct Port
 	char	   *user_name;
 	char	   *cmdline_options;
 	List	   *guc_options;
+	bool		protocol_cursor_enabled;	/* _pq_.protocol_cursor option */
 
 	/*
 	 * The startup packet application name, only used here for the "connection
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 1e3d5bd5867..6464ce8bd51 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -211,3 +211,4 @@ PQdefaultAuthDataHook     208
 PQfullProtocolVersion     209
 appendPQExpBufferVA       210
 PQgetThreadLock           211
+PQsendBindWithCursorOptions 212
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index db9b4c8edbf..feff1507408 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -417,6 +417,10 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 		"SSL-Key-Log-File", "D", 64,
 	offsetof(struct pg_conn, sslkeylogfile)},
 
+	{"protocol_cursor", NULL, "0", NULL,
+		"Protocol-Cursor", "", 1,
+	offsetof(struct pg_conn, protocol_cursor)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -3732,6 +3736,13 @@ keep_going:						/* We will come back to here until there is
 				 * proceed without.
 				 */
 
+				/*
+				 * Set protocol_cursor_enabled flag based on connection
+				 * parameter
+				 */
+				if (conn->protocol_cursor && conn->protocol_cursor[0] == '1')
+					conn->protocol_cursor_enabled = true;
+
 				/* Build the startup packet. */
 				startpacket = pqBuildStartupPacket3(conn, &packetlen,
 													EnvironmentOptions);
diff --git a/src/interfaces/libpq/fe-exec.c b/src/interfaces/libpq/fe-exec.c
index 203d388bdbf..2c72de2baef 100644
--- a/src/interfaces/libpq/fe-exec.c
+++ b/src/interfaces/libpq/fe-exec.c
@@ -1682,6 +1682,137 @@ PQsendQueryPrepared(PGconn *conn,
 						   resultFormat);
 }
 
+/*
+ * PQsendBindWithCursorOptions
+ *		Send a Bind message with cursor options, followed by Describe, but not
+ *		Execute. This creates a named portal with the specified cursor options
+ *		(CURSOR_OPT_* from src/include/nodes/parsenodes.h) that can be fetched
+ *		from later.
+ *
+ *		Non-zero cursorOptions require the _pq_.protocol_cursor protocol extension;
+ *		returns 0 if the extension was not negotiated.  Passing cursorOptions
+ *		as 0 always succeeds and creates a plain named portal.
+ *
+ *		Note: CURSOR_OPT_BINARY has no effect here; binary output is controlled
+ *		by the resultFormat parameter on the subsequent FETCH call.
+ */
+int
+PQsendBindWithCursorOptions(PGconn *conn,
+							const char *stmtName,
+							int nParams,
+							const char *const *paramValues,
+							const int *paramLengths,
+							const int *paramFormats,
+							int resultFormat,
+							const char *portalName,
+							int cursorOptions)
+{
+	PGcmdQueueEntry *entry;
+
+	if (!PQsendQueryStart(conn, true))
+		return 0;
+
+	if (!stmtName)
+	{
+		libpq_append_conn_error(conn, "statement name is a null pointer");
+		return 0;
+	}
+
+	if (!portalName || portalName[0] == '\0')
+	{
+		libpq_append_conn_error(conn, "a named portal is required");
+		return 0;
+	}
+
+	if (cursorOptions != 0 && !conn->protocol_cursor_enabled)
+	{
+		libpq_append_conn_error(conn,
+								"cursor options require the _pq_.protocol_cursor protocol extension");
+		return 0;
+	}
+
+	entry = pqAllocCmdQueueEntry(conn);
+	if (entry == NULL)
+		return 0;
+
+	if (pqPutMsgStart(PqMsg_Bind, conn) < 0 ||
+		pqPuts(portalName ? portalName : "", conn) < 0 ||
+		pqPuts(stmtName, conn) < 0)
+		goto sendFailed;
+
+	if (nParams > 0 && paramFormats)
+	{
+		if (pqPutInt(nParams, 2, conn) < 0)
+			goto sendFailed;
+		for (int i = 0; i < nParams; i++)
+			if (pqPutInt(paramFormats[i], 2, conn) < 0)
+				goto sendFailed;
+	}
+	else if (pqPutInt(0, 2, conn) < 0)
+		goto sendFailed;
+
+	if (pqPutInt(nParams, 2, conn) < 0)
+		goto sendFailed;
+
+	for (int i = 0; i < nParams; i++)
+	{
+		if (paramValues && paramValues[i])
+		{
+			int			len = paramLengths ? paramLengths[i] : strlen(paramValues[i]);
+
+			if (pqPutInt(len, 4, conn) < 0 ||
+				pqPutnchar(paramValues[i], len, conn) < 0)
+				goto sendFailed;
+		}
+		else if (pqPutInt(-1, 4, conn) < 0)
+			goto sendFailed;
+	}
+
+	if (pqPutInt(1, 2, conn) < 0 ||
+		pqPutInt(resultFormat, 2, conn) < 0)
+		goto sendFailed;
+
+	/* Send cursor options if _pq_.protocol_cursor enabled */
+	if (conn->protocol_cursor_enabled)
+	{
+		if (pqPutInt(cursorOptions, 4, conn) < 0)
+			goto sendFailed;
+	}
+
+	if (pqPutMsgEnd(conn) < 0)
+		goto sendFailed;
+
+	if (pqPutMsgStart(PqMsg_Describe, conn) < 0 ||
+		pqPutc('P', conn) < 0 ||
+		pqPuts(portalName ? portalName : "", conn) < 0 ||
+		pqPutMsgEnd(conn) < 0)
+		goto sendFailed;
+
+	/* No Execute message - portal is created but not executed */
+
+	if (conn->pipelineStatus == PQ_PIPELINE_OFF)
+	{
+		if (pqPutMsgStart(PqMsg_Sync, conn) < 0 ||
+			pqPutMsgEnd(conn) < 0)
+			goto sendFailed;
+	}
+
+	entry->queryclass = PGQUERY_DESCRIBE;
+
+	if (pqPipelineFlush(conn) < 0)
+		goto sendFailed;
+
+	/* OK, it's launched! */
+	pqAppendCmdQueueEntry(conn, entry);
+
+	conn->asyncStatus = PGASYNC_BUSY;
+	return 1;
+
+sendFailed:
+	pqRecycleCmdQueueEntry(conn, entry);
+	return 0;
+}
+
 /*
  * PQsendQueryStart
  *	Common startup code for PQsendQuery and sibling routines
diff --git a/src/interfaces/libpq/fe-protocol3.c b/src/interfaces/libpq/fe-protocol3.c
index 8c1fda5caf0..b2ab3f2263a 100644
--- a/src/interfaces/libpq/fe-protocol3.c
+++ b/src/interfaces/libpq/fe-protocol3.c
@@ -1544,6 +1544,16 @@ pqGetNegotiateProtocolVersion3(PGconn *conn)
 			strcmp(conn->workBuffer.data, "_pq_.test_protocol_negotiation") == 0)
 		{
 			found_test_protocol_negotiation = true;
+			continue;
+		}
+
+		/*
+		 * Handle rejected protocol extensions we requested. Disable the
+		 * corresponding feature so the client doesn't try to use it.
+		 */
+		if (strcmp(conn->workBuffer.data, "_pq_.protocol_cursor") == 0)
+		{
+			conn->protocol_cursor_enabled = false;
 		}
 		else
 		{
@@ -2521,6 +2531,10 @@ build_startup_packet(const PGconn *conn, char *packet,
 	if (conn->pversion == PG_PROTOCOL_GREASE)
 		ADD_STARTUP_OPTION("_pq_.test_protocol_negotiation", "");
 
+	/* Add _pq_.protocol_cursor option if enabled */
+	if (conn->protocol_cursor && conn->protocol_cursor[0] == '1')
+		ADD_STARTUP_OPTION("_pq_.protocol_cursor", "true");
+
 	/* Add any environment-driven GUC settings needed */
 	for (next_eo = options; next_eo->envName; next_eo++)
 	{
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index f06e7a972c3..16add226a6f 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -535,6 +535,10 @@ extern int	PQsendQueryPrepared(PGconn *conn,
 								const int *paramLengths,
 								const int *paramFormats,
 								int resultFormat);
+extern int	PQsendBindWithCursorOptions(PGconn *conn, const char *stmtName,
+										int nParams, const char *const *paramValues,
+										const int *paramLengths, const int *paramFormats,
+										int resultFormat, const char *portalName, int cursorOptions);
 extern int	PQsetSingleRowMode(PGconn *conn);
 extern int	PQsetChunkedRowsMode(PGconn *conn, int chunkSize);
 extern PGresult *PQgetResult(PGconn *conn);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index bd7eb59f5f8..f7e1980981f 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -430,6 +430,7 @@ struct pg_conn
 	char	   *scram_client_key;	/* base64-encoded SCRAM client key */
 	char	   *scram_server_key;	/* base64-encoded SCRAM server key */
 	char	   *sslkeylogfile;	/* where should the client write ssl keylogs */
+	char	   *protocol_cursor;	/* enable _pq_.protocol_cursor option */
 
 	bool		cancelRequest;	/* true if this connection is used to send a
 								 * cancel request, instead of being a normal
@@ -504,6 +505,7 @@ struct pg_conn
 	int			sversion;		/* server version, e.g. 70401 for 7.4.1 */
 	bool		pversion_negotiated;	/* true if NegotiateProtocolVersion
 										 * was received */
+	bool		protocol_cursor_enabled;	/* _pq_.protocol_cursor option */
 	bool		auth_req_received;	/* true if any type of auth req received */
 	bool		password_needed;	/* true if server demanded a password */
 	bool		gssapi_used;	/* true if authenticated via gssapi */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 28ce3b35eda..357fb97ea8f 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  index \
+		  libpq_protocol_cursor \
 		  libpq_pipeline \
 		  oauth_validator \
 		  plsample \
diff --git a/src/test/modules/libpq_protocol_cursor/.gitignore b/src/test/modules/libpq_protocol_cursor/.gitignore
new file mode 100644
index 00000000000..8f55ad176bf
--- /dev/null
+++ b/src/test/modules/libpq_protocol_cursor/.gitignore
@@ -0,0 +1,5 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
+/libpq_protocol_cursor
diff --git a/src/test/modules/libpq_protocol_cursor/Makefile b/src/test/modules/libpq_protocol_cursor/Makefile
new file mode 100644
index 00000000000..1fa00af1530
--- /dev/null
+++ b/src/test/modules/libpq_protocol_cursor/Makefile
@@ -0,0 +1,25 @@
+# src/test/modules/libpq_protocol_cursor/Makefile
+
+PGFILEDESC = "libpq_protocol_cursor - test program for extended query protocol cursors"
+PGAPPICON = win32
+
+PROGRAM = libpq_protocol_cursor
+OBJS = $(WIN32RES) libpq_protocol_cursor.o
+
+NO_INSTALL = 1
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/libpq_protocol_cursor
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/libpq_protocol_cursor/libpq_protocol_cursor.c b/src/test/modules/libpq_protocol_cursor/libpq_protocol_cursor.c
new file mode 100644
index 00000000000..33e7d21e596
--- /dev/null
+++ b/src/test/modules/libpq_protocol_cursor/libpq_protocol_cursor.c
@@ -0,0 +1,799 @@
+/*-------------------------------------------------------------------------
+ *
+ * libpq_protocol_cursor.c
+ *		Tests for extended query protocol cursor options via
+ *		PQsendBindWithCursorOptions (_pq_.protocol_cursor protocol extension).
+ *
+ * Copyright (c) 2024-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/libpq_protocol_cursor/libpq_protocol_cursor.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <string.h>
+
+#include "libpq-fe.h"
+#include "pg_getopt.h"
+#include "port/pg_bswap.h"
+
+/*
+ * Cursor option flags from src/include/nodes/parsenodes.h.
+ * Duplicated here because frontend code cannot include server headers.
+ */
+#define CURSOR_OPT_SCROLL		0x0002
+#define CURSOR_OPT_NO_SCROLL	0x0004
+#define CURSOR_OPT_HOLD			0x0020
+
+static const char *const progname = "libpq_protocol_cursor";
+
+static void exit_nicely(PGconn *conn);
+pg_noreturn static void pg_fatal_impl(int line, const char *fmt,...)
+			pg_attribute_printf(2, 3);
+
+static void
+exit_nicely(PGconn *conn)
+{
+	PQfinish(conn);
+	exit(1);
+}
+
+/*
+ * The following few functions are wrapped in macros to make the reported line
+ * number in an error match the line number of the invocation.
+ */
+
+/*
+ * Print an error to stderr and terminate the program.
+ */
+#define pg_fatal(...) pg_fatal_impl(__LINE__, __VA_ARGS__)
+pg_noreturn static void
+pg_fatal_impl(int line, const char *fmt,...)
+{
+	va_list		args;
+
+	fflush(stdout);
+
+	fprintf(stderr, "\n%s:%d: ", progname, line);
+	va_start(args, fmt);
+	vfprintf(stderr, fmt, args);
+	va_end(args);
+	Assert(fmt[strlen(fmt) - 1] != '\n');
+	fprintf(stderr, "\n");
+	exit(1);
+}
+
+/*
+ * Check that libpq next returns a PGresult with the specified status,
+ * returning the PGresult so that caller can perform additional checks.
+ */
+#define confirm_result_status(conn, status) confirm_result_status_impl(__LINE__, conn, status)
+static PGresult *
+confirm_result_status_impl(int line, PGconn *conn, ExecStatusType status)
+{
+	PGresult   *res;
+
+	res = PQgetResult(conn);
+	if (res == NULL)
+		pg_fatal_impl(line, "PQgetResult returned null unexpectedly: %s",
+					  PQerrorMessage(conn));
+	if (PQresultStatus(res) != status)
+		pg_fatal_impl(line, "PQgetResult returned status %s, expected %s: %s",
+					  PQresStatus(PQresultStatus(res)),
+					  PQresStatus(status),
+					  PQerrorMessage(conn));
+	return res;
+}
+
+/*
+ * Check that libpq next returns a PGresult with the specified status,
+ * then free the PGresult.
+ */
+#define consume_result_status(conn, status) consume_result_status_impl(__LINE__, conn, status)
+static void
+consume_result_status_impl(int line, PGconn *conn, ExecStatusType status)
+{
+	PGresult   *res;
+
+	res = confirm_result_status_impl(line, conn, status);
+	PQclear(res);
+}
+
+/*
+ * Check that libpq next returns a null PGresult.
+ */
+#define consume_null_result(conn) consume_null_result_impl(__LINE__, conn)
+static void
+consume_null_result_impl(int line, PGconn *conn)
+{
+	PGresult   *res;
+
+	res = PQgetResult(conn);
+	if (res != NULL)
+		pg_fatal_impl(line, "expected NULL PGresult, got %s: %s",
+					  PQresStatus(PQresultStatus(res)),
+					  PQerrorMessage(conn));
+}
+
+/*
+ * Test holdable cursor: create a portal with CURSOR_OPT_HOLD via Bind,
+ * commit the transaction, then FETCH from the surviving portal.
+ */
+static void
+test_holdable_cursor(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_holdable_cursor... ");
+
+	res = PQexec(conn, "BEGIN");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("BEGIN failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "CREATE TEMP TABLE IF NOT EXISTS holdable_test(id int)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("CREATE TABLE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "INSERT INTO holdable_test VALUES (1), (2), (3)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("INSERT failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQprepare(conn, "holdstmt", "SELECT * FROM holdable_test", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	if (PQsendBindWithCursorOptions(conn, "holdstmt", 0, NULL, NULL, NULL, 0,
+									"holdportal", CURSOR_OPT_HOLD) != 1)
+		pg_fatal("PQsendBindWithCursorOptions failed: %s", PQerrorMessage(conn));
+
+	if (PQsendQueryParams(conn, "COMMIT", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("COMMIT failed: %s", PQerrorMessage(conn));
+
+	if (PQsendQueryParams(conn, "FETCH ALL FROM holdportal", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH failed: %s", PQerrorMessage(conn));
+
+	if (PQsendClosePortal(conn, "holdportal") != 1)
+		pg_fatal("PQsendClosePortal failed: %s", PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result (RowDescription metadata) */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	if (PQnfields(res) != 1)
+		pg_fatal("expected 1 field, got %d", PQnfields(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* COMMIT result */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	/* FETCH after commit */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 3)
+		pg_fatal("expected 3 rows after commit, got %d", PQntuples(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* CLOSE */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test binary cursor: create a portal with CURSOR_OPT_BINARY and verify
+ * results come back in binary format.
+ */
+static void
+test_binary_cursor(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_binary_cursor... ");
+
+	res = PQexec(conn, "BEGIN");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("BEGIN failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQprepare(conn, "binstmt", "SELECT 42::int4", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	/*
+	 * Create portal without cursor options — binary output is controlled by
+	 * resultFormat on the FETCH, not by CURSOR_OPT_BINARY.
+	 */
+	if (PQsendBindWithCursorOptions(conn, "binstmt", 0, NULL, NULL, NULL, 0,
+									"binportal", 0) != 1)
+		pg_fatal("PQsendBindWithCursorOptions failed: %s", PQerrorMessage(conn));
+
+	/* FETCH with resultFormat=1 to request binary output */
+	if (PQsendQueryParams(conn, "FETCH ALL FROM binportal", 0, NULL, NULL, NULL, NULL, 1) != 1)
+		pg_fatal("FETCH failed: %s", PQerrorMessage(conn));
+
+	if (PQsendClosePortal(conn, "binportal") != 1)
+		pg_fatal("PQsendClosePortal failed: %s", PQerrorMessage(conn));
+
+	if (PQsendQueryParams(conn, "COMMIT", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("COMMIT failed: %s", PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result (RowDescription metadata) */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	if (PQnfields(res) != 1)
+		pg_fatal("expected 1 field, got %d", PQnfields(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* FETCH result */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 1)
+		pg_fatal("expected 1 row, got %d", PQntuples(res));
+	if (PQfformat(res, 0) != 1)
+		pg_fatal("expected binary format (1), got %d", PQfformat(res, 0));
+	if (PQgetlength(res, 0, 0) != 4)
+		pg_fatal("expected 4-byte int4, got %d bytes", PQgetlength(res, 0, 0));
+	{
+		int32		val;
+
+		memcpy(&val, PQgetvalue(res, 0, 0), 4);
+		val = (int32) pg_ntoh32(val);
+		if (val != 42)
+			pg_fatal("expected value 42, got %d", val);
+	}
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* CLOSE */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	/* COMMIT */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test scroll cursor: create a portal with CURSOR_OPT_SCROLL and verify
+ * backward fetching works.
+ */
+static void
+test_scroll_cursor(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_scroll_cursor... ");
+
+	res = PQexec(conn, "BEGIN");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("BEGIN failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "CREATE TEMP TABLE IF NOT EXISTS scroll_test(id int)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("CREATE TABLE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "INSERT INTO scroll_test VALUES (1), (2), (3)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("INSERT failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQprepare(conn, "scrollstmt", "SELECT * FROM scroll_test ORDER BY id", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	if (PQsendBindWithCursorOptions(conn, "scrollstmt", 0, NULL, NULL, NULL, 0,
+									"scrollportal", CURSOR_OPT_SCROLL) != 1)
+		pg_fatal("PQsendBindWithCursorOptions failed: %s", PQerrorMessage(conn));
+
+	/* Fetch forward then backward */
+	if (PQsendQueryParams(conn, "FETCH 2 FROM scrollportal", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH forward failed: %s", PQerrorMessage(conn));
+
+	if (PQsendQueryParams(conn, "FETCH BACKWARD 1 FROM scrollportal", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH backward failed: %s", PQerrorMessage(conn));
+
+	if (PQsendClosePortal(conn, "scrollportal") != 1)
+		pg_fatal("PQsendClosePortal failed: %s", PQerrorMessage(conn));
+
+	if (PQsendQueryParams(conn, "COMMIT", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("COMMIT failed: %s", PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result (RowDescription metadata) */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	if (PQnfields(res) != 1)
+		pg_fatal("expected 1 field, got %d", PQnfields(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* FETCH forward 2 */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 2)
+		pg_fatal("expected 2 rows from forward fetch, got %d", PQntuples(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* FETCH backward 1 - should get row with id=1 */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 1)
+		pg_fatal("expected 1 row from backward fetch, got %d", PQntuples(res));
+	if (strcmp(PQgetvalue(res, 0, 0), "1") != 0)
+		pg_fatal("expected value '1' from backward fetch, got '%s'", PQgetvalue(res, 0, 0));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* CLOSE */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	/* COMMIT */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test no-scroll cursor: create a portal with CURSOR_OPT_NO_SCROLL and
+ * verify backward fetching is rejected.
+ */
+static void
+test_no_scroll_cursor(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_no_scroll_cursor... ");
+
+	res = PQexec(conn, "BEGIN");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("BEGIN failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "CREATE TEMP TABLE IF NOT EXISTS noscroll_test(id int)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("CREATE TABLE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "INSERT INTO noscroll_test VALUES (1), (2), (3)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("INSERT failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQprepare(conn, "noscrollstmt", "SELECT * FROM noscroll_test ORDER BY id", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	if (PQsendBindWithCursorOptions(conn, "noscrollstmt", 0, NULL, NULL, NULL, 0,
+									"noscrollportal", CURSOR_OPT_NO_SCROLL) != 1)
+		pg_fatal("PQsendBindWithCursorOptions failed: %s", PQerrorMessage(conn));
+
+	/* Forward fetch should work */
+	if (PQsendQueryParams(conn, "FETCH 1 FROM noscrollportal", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH forward failed: %s", PQerrorMessage(conn));
+
+	/* Backward fetch should fail */
+	if (PQsendQueryParams(conn, "FETCH BACKWARD 1 FROM noscrollportal", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH backward send failed: %s", PQerrorMessage(conn));
+
+	if (PQsendClosePortal(conn, "noscrollportal") != 1)
+		pg_fatal("PQsendClosePortal failed: %s", PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result (RowDescription metadata) */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	if (PQnfields(res) != 1)
+		pg_fatal("expected 1 field, got %d", PQnfields(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* FETCH forward 1 - should succeed */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 1)
+		pg_fatal("expected 1 row from forward fetch, got %d", PQntuples(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* FETCH backward - should fail */
+	consume_result_status(conn, PGRES_FATAL_ERROR);
+	consume_null_result(conn);
+
+	/* CLOSE - pipeline is aborted after the error */
+	consume_result_status(conn, PGRES_PIPELINE_ABORTED);
+	consume_null_result(conn);
+
+	/* Pipeline sync resets the abort state */
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	/* Clean up: rollback the failed transaction */
+	res = PQexec(conn, "ROLLBACK");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("ROLLBACK failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test combined cursor options: create a holdable + scrollable portal,
+ * commit the transaction, then fetch backward from the surviving portal.
+ */
+static void
+test_hold_scroll_cursor(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_hold_scroll_cursor... ");
+
+	res = PQexec(conn, "BEGIN");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("BEGIN failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "CREATE TEMP TABLE IF NOT EXISTS holdscroll_test(id int)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("CREATE TABLE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQexec(conn, "INSERT INTO holdscroll_test VALUES (1), (2), (3)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("INSERT failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQprepare(conn, "holdscrollstmt",
+					"SELECT * FROM holdscroll_test ORDER BY id", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	/* Combine HOLD and SCROLL options */
+	if (PQsendBindWithCursorOptions(conn, "holdscrollstmt", 0, NULL, NULL, NULL, 0,
+									"holdscrollportal",
+									CURSOR_OPT_HOLD | CURSOR_OPT_SCROLL) != 1)
+		pg_fatal("PQsendBindWithCursorOptions failed: %s", PQerrorMessage(conn));
+
+	if (PQsendQueryParams(conn, "COMMIT", 0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("COMMIT failed: %s", PQerrorMessage(conn));
+
+	/* Fetch forward after commit — holdable keeps the portal alive */
+	if (PQsendQueryParams(conn, "FETCH 2 FROM holdscrollportal",
+						  0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH forward failed: %s", PQerrorMessage(conn));
+
+	/* Fetch backward — scroll option allows this */
+	if (PQsendQueryParams(conn, "FETCH BACKWARD 1 FROM holdscrollportal",
+						  0, NULL, NULL, NULL, NULL, 0) != 1)
+		pg_fatal("FETCH backward failed: %s", PQerrorMessage(conn));
+
+	if (PQsendClosePortal(conn, "holdscrollportal") != 1)
+		pg_fatal("PQsendClosePortal failed: %s", PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result (RowDescription metadata) */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	if (PQnfields(res) != 1)
+		pg_fatal("expected 1 field, got %d", PQnfields(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* COMMIT */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	/* FETCH forward 2 */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 2)
+		pg_fatal("expected 2 rows from forward fetch, got %d", PQntuples(res));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* FETCH backward 1 — should get row with id=1 */
+	res = confirm_result_status(conn, PGRES_TUPLES_OK);
+	if (PQntuples(res) != 1)
+		pg_fatal("expected 1 row from backward fetch, got %d", PQntuples(res));
+	if (strcmp(PQgetvalue(res, 0, 0), "1") != 0)
+		pg_fatal("expected value '1' from backward fetch, got '%s'",
+				 PQgetvalue(res, 0, 0));
+	PQclear(res);
+	consume_null_result(conn);
+
+	/* CLOSE */
+	consume_result_status(conn, PGRES_COMMAND_OK);
+	consume_null_result(conn);
+
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test that cursor options on a DML statement are harmlessly ignored.
+ * The portal gets cursorOptions set, but since it's not a DECLARE CURSOR,
+ * the options have no effect.
+ */
+static void
+test_dml_with_cursor_options(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_dml_with_cursor_options... ");
+
+	res = PQexec(conn, "CREATE TEMP TABLE IF NOT EXISTS dml_test(id int)");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("CREATE TABLE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	res = PQprepare(conn, "dmlstmt",
+					"INSERT INTO dml_test VALUES (1), (2), (3)", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	/* Pass SCROLL option on a DML — should be silently ignored */
+	if (PQsendBindWithCursorOptions(conn, "dmlstmt", 0, NULL, NULL, NULL, 0,
+									"dmlportal", CURSOR_OPT_SCROLL) != 1)
+		pg_fatal("PQsendBindWithCursorOptions failed: %s", PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	PQclear(res);
+	consume_null_result(conn);
+
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	/* Verify the INSERT didn't actually execute (Bind+Describe only) */
+	res = PQexec(conn, "SELECT count(*) FROM dml_test");
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pg_fatal("SELECT count failed: %s", PQerrorMessage(conn));
+	if (strcmp(PQgetvalue(res, 0, 0), "0") != 0)
+		pg_fatal("expected 0 rows (Bind+Describe doesn't execute), got %s",
+				 PQgetvalue(res, 0, 0));
+	PQclear(res);
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test client-side validation: PQsendBindWithCursorOptions should reject
+ * an unnamed (empty) portal.
+ */
+static void
+test_unnamed_portal_rejected(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_unnamed_portal_rejected... ");
+
+	res = PQprepare(conn, "rejectstmt", "SELECT 1", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	/* Empty portal name should be rejected client-side */
+	if (PQsendBindWithCursorOptions(conn, "rejectstmt", 0, NULL, NULL, NULL, 0,
+									"", CURSOR_OPT_HOLD) != 0)
+		pg_fatal("expected PQsendBindWithCursorOptions to reject empty portal name");
+
+	/* NULL portal name should also be rejected */
+	if (PQsendBindWithCursorOptions(conn, "rejectstmt", 0, NULL, NULL, NULL, 0,
+									NULL, CURSOR_OPT_HOLD) != 0)
+		pg_fatal("expected PQsendBindWithCursorOptions to reject NULL portal name");
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	fprintf(stderr, "ok\n");
+}
+
+/*
+ * Test that cursor options are rejected when _pq_.protocol_cursor is not negotiated.
+ * HOLD is requested but the extension is disabled, so the API call itself
+ * returns 0.
+ */
+static void
+test_cursor_options_without_extension(PGconn *conn)
+{
+	PGresult   *res;
+
+	fprintf(stderr, "test_cursor_options_without_extension... ");
+
+	res = PQprepare(conn, "noextstmt", "SELECT 1", 0, NULL);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("PREPARE failed: %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (PQenterPipelineMode(conn) != 1)
+		pg_fatal("failed to enter pipeline mode: %s", PQerrorMessage(conn));
+
+	/* Non-zero cursorOptions should be rejected when extension is disabled */
+	if (PQsendBindWithCursorOptions(conn, "noextstmt", 0, NULL, NULL, NULL, 0,
+									"noextportal", CURSOR_OPT_HOLD) != 0)
+		pg_fatal("expected PQsendBindWithCursorOptions to reject cursor options");
+
+	/* Zero cursorOptions should still succeed */
+	if (PQsendBindWithCursorOptions(conn, "noextstmt", 0, NULL, NULL, NULL, 0,
+									"noextportal", 0) != 1)
+		pg_fatal("PQsendBindWithCursorOptions with zero options failed: %s",
+				 PQerrorMessage(conn));
+
+	if (PQpipelineSync(conn) != 1)
+		pg_fatal("pipeline sync failed: %s", PQerrorMessage(conn));
+
+	/* Bind+Describe result */
+	res = confirm_result_status(conn, PGRES_COMMAND_OK);
+	PQclear(res);
+	consume_null_result(conn);
+
+	consume_result_status(conn, PGRES_PIPELINE_SYNC);
+	consume_null_result(conn);
+
+	if (PQexitPipelineMode(conn) != 1)
+		pg_fatal("failed to exit pipeline mode: %s", PQerrorMessage(conn));
+
+	fprintf(stderr, "ok\n");
+}
+
+static void
+usage(const char *progname)
+{
+	fprintf(stderr, "%s tests extended query protocol cursor options.\n\n", progname);
+	fprintf(stderr, "Usage:\n");
+	fprintf(stderr, "  %s tests\n", progname);
+	fprintf(stderr, "  %s TESTNAME [CONNINFO]\n", progname);
+}
+
+static void
+print_test_list(void)
+{
+	printf("binary_cursor\n");
+	printf("dml_with_cursor_options\n");
+	printf("hold_scroll_cursor\n");
+	printf("holdable_cursor\n");
+	printf("no_scroll_cursor\n");
+	printf("scroll_cursor\n");
+	printf("unnamed_portal_rejected\n");
+	printf("cursor_options_without_extension\n");
+}
+
+int
+main(int argc, char **argv)
+{
+	const char *conninfo = "";
+	PGconn	   *conn;
+	char	   *testname;
+	PGresult   *res;
+
+	if (argc < 2)
+	{
+		usage(argv[0]);
+		exit(1);
+	}
+
+	testname = argv[1];
+
+	if (strcmp(testname, "tests") == 0)
+	{
+		print_test_list();
+		exit(0);
+	}
+
+	if (argc > 2)
+		conninfo = argv[2];
+
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		exit_nicely(conn);
+	}
+
+	res = PQexec(conn, "SET lc_messages TO \"C\"");
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("failed to set \"lc_messages\": %s", PQerrorMessage(conn));
+	PQclear(res);
+
+	if (strcmp(testname, "binary_cursor") == 0)
+		test_binary_cursor(conn);
+	else if (strcmp(testname, "cursor_options_without_extension") == 0)
+		test_cursor_options_without_extension(conn);
+	else if (strcmp(testname, "dml_with_cursor_options") == 0)
+		test_dml_with_cursor_options(conn);
+	else if (strcmp(testname, "hold_scroll_cursor") == 0)
+		test_hold_scroll_cursor(conn);
+	else if (strcmp(testname, "holdable_cursor") == 0)
+		test_holdable_cursor(conn);
+	else if (strcmp(testname, "no_scroll_cursor") == 0)
+		test_no_scroll_cursor(conn);
+	else if (strcmp(testname, "scroll_cursor") == 0)
+		test_scroll_cursor(conn);
+	else if (strcmp(testname, "unnamed_portal_rejected") == 0)
+		test_unnamed_portal_rejected(conn);
+	else
+	{
+		fprintf(stderr, "\"%s\" is not a recognized test name\n", testname);
+		exit(1);
+	}
+
+	PQfinish(conn);
+	return 0;
+}
diff --git a/src/test/modules/libpq_protocol_cursor/meson.build b/src/test/modules/libpq_protocol_cursor/meson.build
new file mode 100644
index 00000000000..cc7624012cb
--- /dev/null
+++ b/src/test/modules/libpq_protocol_cursor/meson.build
@@ -0,0 +1,32 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+libpq_protocol_cursor_sources = files(
+  'libpq_protocol_cursor.c',
+)
+
+if host_system == 'windows'
+  libpq_protocol_cursor_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'libpq_protocol_cursor',
+    '--FILEDESC', 'libpq_protocol_cursor - test program for extended query protocol cursors',])
+endif
+
+libpq_protocol_cursor = executable('libpq_protocol_cursor',
+  libpq_protocol_cursor_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += libpq_protocol_cursor
+
+tests += {
+  'name': 'libpq_protocol_cursor',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_libpq_protocol_cursor.pl',
+    ],
+    'deps': [libpq_protocol_cursor],
+  },
+}
diff --git a/src/test/modules/libpq_protocol_cursor/t/001_libpq_protocol_cursor.pl b/src/test/modules/libpq_protocol_cursor/t/001_libpq_protocol_cursor.pl
new file mode 100644
index 00000000000..860631c8947
--- /dev/null
+++ b/src/test/modules/libpq_protocol_cursor/t/001_libpq_protocol_cursor.pl
@@ -0,0 +1,42 @@
+# Copyright (c) 2024-2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->start;
+
+my ($out, $err) = run_command(['libpq_protocol_cursor', 'tests']);
+die "oops: $err" unless $err eq '';
+my @tests = split(/\s+/, $out);
+
+for my $testname (@tests)
+{
+	# cursor_options_without_extension must run without protocol_cursor enabled
+	my $connstr = $node->connstr('postgres');
+	if ($testname eq 'cursor_options_without_extension')
+	{
+		$connstr .= " protocol_cursor=0";
+	}
+	else
+	{
+		$connstr .= " protocol_cursor=1 max_protocol_version=latest";
+	}
+
+	$node->command_ok(
+		[
+			'libpq_protocol_cursor',
+			$testname,
+			$connstr
+		],
+		"libpq_protocol_cursor $testname");
+}
+
+$node->stop('fast');
+
+done_testing();
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 3ac291656c1..5627a274164 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('index')
 subdir('injection_points')
 subdir('ldap_password_func')
+subdir('libpq_protocol_cursor')
 subdir('libpq_pipeline')
 subdir('nbtree')
 subdir('oauth_validator')
-- 
2.47.3

