From 1008093a2b76cb57bee12df7223d100bab49eded Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Tue, 11 Aug 2026 18:16:28 +0000
Subject: [PATCH] psql: count every COPY FROM STDIN when scanning a query
 string

When SendQuery() is not told how many COPY FROM STDIN commands the query
string contains (num_copy_from_stdin < 0, as for -c, \gexec and forced
sends), it scanned the string to count them itself.  But it called
psql_scan() only once, which stops at the first semicolon, so any COPY
FROM STDIN past the first sub-command was not counted.  psql then treated
the server's COPY_IN response as unexpected and closed the connection
with "unexpected COPY_IN result, aborting connection", e.g. for

    psql -c "SELECT 1; COPY tab FROM STDIN" < data

which worked before the counting was added.

Loop psql_scan() over the whole string so the count accumulates across
all sub-commands.  Add a TAP test covering a lone COPY, a COPY after
another command, and two COPYs in one string.

Oversight in commit 3045a25ba81.
---
 src/bin/psql/common.c                  | 12 +++-
 src/bin/psql/meson.build               |  1 +
 src/bin/psql/t/040_copy_stdin_count.pl | 78 ++++++++++++++++++++++++++
 3 files changed, 90 insertions(+), 1 deletion(-)
 create mode 100644 src/bin/psql/t/040_copy_stdin_count.pl

diff --git a/src/bin/psql/common.c b/src/bin/psql/common.c
index 314bf2388ac..e5a37d7073f 100644
--- a/src/bin/psql/common.c
+++ b/src/bin/psql/common.c
@@ -1796,13 +1796,23 @@ ExecQueryAndProcessResults(const char *query,
 		PsqlScanState scan_state;
 		PQExpBuffer query_buf;
 		promptStatus_t prompt_tmp;
+		PsqlScanResult scan_result;
 
 		scan_state = psql_scan_create(&psqlscan_callbacks);
 		psql_scan_setup(scan_state, query, strlen(query),
 						pset.encoding, standard_strings());
 		query_buf = createPQExpBuffer();
 
-		(void) psql_scan(scan_state, query_buf, &prompt_tmp);
+		/*
+		 * A semicolon ends only one sub-command; keep scanning so that COPY
+		 * FROM STDIN commands past the first semicolon are counted too.  The
+		 * count accumulates in scan_state across the psql_scan() calls.
+		 */
+		do
+		{
+			scan_result = psql_scan(scan_state, query_buf, &prompt_tmp);
+			resetPQExpBuffer(query_buf);
+		} while (scan_result == PSCAN_SEMICOLON);
 
 		num_copy_from_stdin = psql_scan_count_copy_from_stdin(scan_state);
 
diff --git a/src/bin/psql/meson.build b/src/bin/psql/meson.build
index 922b2845267..d6a671d0017 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_copy_stdin_count.pl',
     ],
   },
 }
diff --git a/src/bin/psql/t/040_copy_stdin_count.pl b/src/bin/psql/t/040_copy_stdin_count.pl
new file mode 100644
index 00000000000..7eee668535e
--- /dev/null
+++ b/src/bin/psql/t/040_copy_stdin_count.pl
@@ -0,0 +1,78 @@
+
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test that psql counts every COPY ... FROM STDIN in a query string when it has
+# to scan the string itself (the -c / \gexec / forced-send paths).  A COPY that
+# is not the first sub-command must still be recognized, otherwise psql treats
+# the server's COPY_IN response as unexpected and aborts the connection.
+
+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;
+
+$node->safe_psql('postgres', 'CREATE TABLE t (a int)');
+
+# Run "psql -c $sql" with $stdin fed to psql's stdin (so COPY FROM STDIN reads
+# it).  Returns (success, stdout, stderr).
+sub psql_c_stdin
+{
+	my ($sql, $stdin) = @_;
+	my ($stdout, $stderr) = ('', '');
+
+	my $ret = IPC::Run::run(
+		[
+			'psql', '-X', '-v' => 'ON_ERROR_STOP=1',
+			'-d' => $node->connstr('postgres'),
+			'-c' => $sql
+		],
+		'<' => \$stdin,
+		'>' => \$stdout,
+		'2>' => \$stderr);
+
+	return ($ret, $stdout, $stderr);
+}
+
+my @cases = (
+	{
+		name => 'single COPY FROM STDIN',
+		sql => 'COPY t FROM STDIN',
+		data => "10\n20\n\\.\n",
+		rows => 2,
+	},
+	{
+		name => 'COPY FROM STDIN after another command',
+		sql => 'SELECT 1; COPY t FROM STDIN',
+		data => "30\n40\n\\.\n",
+		rows => 2,
+	},
+	{
+		name => 'two COPY FROM STDIN in one string',
+		sql => 'COPY t FROM STDIN; COPY t FROM STDIN',
+		data => "50\n\\.\n60\n\\.\n",
+		rows => 2,
+	});
+
+foreach my $c (@cases)
+{
+	$node->safe_psql('postgres', 'TRUNCATE t');
+
+	my ($ok, $stdout, $stderr) = psql_c_stdin($c->{sql}, $c->{data});
+
+	ok($ok, "$c->{name}: psql exits 0");
+	unlike($stderr, qr/unexpected COPY_IN result/,
+		"$c->{name}: connection not aborted");
+
+	my $count = $node->safe_psql('postgres', 'SELECT count(*) FROM t');
+	is($count, $c->{rows}, "$c->{name}: all rows loaded");
+}
+
+$node->stop;
+
+done_testing();
-- 
2.54.0

