pg_upgrade can stall when localhost first reaches a refused IPv6
endpoint on Windows. Its aggregate select loop omits the exception
set, so PQconnectPoll does not process the failure and try IPv4. Libpq
already handles this in its single-socket helper.

The patch handles exceptional readiness in pg_upgrade's existing state
machine. Its Windows TAP test checks both successful-first IPv4 and
refused-first localhost fallback, skipping when resolver/refusal
preconditions are unavailable.

All seven assertions pass on MSVC ARM64, MSVC x64 and MinGW x64. The
x64 runs use emulation on the ARM64 host. Earlier copied-upgrade tests
from released 18.0 x64 and official 19rc1 ARM64 also preserve the
seeded data.

The combined patch includes the tested production fix and test
controls. Exact revisions and coverage are recorded with the evidence.

Best,

Harrison
diff --git a/src/bin/pg_upgrade/meson.build b/src/bin/pg_upgrade/meson.build
index ffbf6ae8d75..9b5f2688626 100644
--- a/src/bin/pg_upgrade/meson.build
+++ b/src/bin/pg_upgrade/meson.build
@@ -69,6 +69,7 @@ tests += {
       't/006_transfer_modes.pl',
       't/007_multixact_conversion.pl',
       't/008_extension_control_path.pl',
+      't/009_connect_failure.pl',
     ],
     'deps': [test_ext],
     'test_kwargs': {'priority': 40}, # pg_upgrade tests are slow
diff --git a/src/bin/pg_upgrade/t/009_connect_failure.pl b/src/bin/pg_upgrade/t/009_connect_failure.pl
new file mode 100644
index 00000000000..50b8c7d1bf2
--- /dev/null
+++ b/src/bin/pg_upgrade/t/009_connect_failure.pl
@@ -0,0 +1,125 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Failed nonblocking connects must wake pg_upgrade's Windows task loop.
+use strict;
+use warnings FATAL => 'all';
+
+use IPC::Run;
+use Errno qw(ECONNREFUSED);
+use Socket
+  qw(AF_UNSPEC AF_INET6 SOCK_STREAM NI_NUMERICHOST getaddrinfo getnameinfo);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+plan skip_all => 'Windows select() connection-failure behavior'
+  unless $windows_os;
+
+# This optional module is not bundled with every supported Perl version.
+eval { require IO::Socket::IP; 1; }
+  or plan skip_all => 'IO::Socket::IP is required for the IPv6 precondition';
+
+# This trigger needs localhost to try IPv6 before the IPv4-only server.
+# Skip explicitly when the resolver cannot supply that ordering.
+my ($lookup_error, @addresses) = getaddrinfo('localhost', '5432',
+	{ family => AF_UNSPEC, socktype => SOCK_STREAM });
+plan skip_all => 'localhost address lookup failed' if $lookup_error;
+my @hosts;
+for my $address (@addresses)
+{
+	my ($name_error, $host) = getnameinfo($address->{addr}, NI_NUMERICHOST);
+	plan skip_all => 'numeric localhost lookup failed' if $name_error;
+	push @hosts, $host;
+}
+plan skip_all => 'localhost must resolve to ::1 first and include 127.0.0.1'
+  unless @hosts && $hosts[0] eq '::1' && grep { $_ eq '127.0.0.1' } @hosts;
+
+my $oldnode = PostgreSQL::Test::Cluster->new('old_node');
+my $newnode = PostgreSQL::Test::Cluster->new('new_node');
+$oldnode->init(extra => ['--locale=C']);
+$newnode->init(extra => ['--locale=C']);
+$oldnode->append_conf('postgresql.conf', "listen_addresses = '127.0.0.1'");
+$newnode->append_conf('postgresql.conf', "listen_addresses = '127.0.0.1'");
+$oldnode->start;
+
+# Confirm the initial endpoint refuses connections on this owned port.
+my $ipv6_connection = IO::Socket::IP->new(
+	PeerHost => '::1',
+	PeerPort => $oldnode->port,
+	Family => AF_INET6,
+	Type => SOCK_STREAM,
+	Timeout => 3);
+my $connection_refused = $!{ECONNREFUSED};
+my $connection_error = "$!";
+if ($ipv6_connection)
+{
+	close $ipv6_connection;
+	$oldnode->stop;
+	plan skip_all => 'the IPv6 endpoint unexpectedly accepts connections';
+}
+elsif (!$connection_refused)
+{
+	$oldnode->stop;
+	plan skip_all =>
+	  "IPv6 endpoint did not refuse the connection: $connection_error";
+}
+
+# pg_upgrade writes its logs relative to its working directory.
+chdir $PostgreSQL::Test::Utils::tmp_check;
+
+my @command = (
+	'pg_upgrade', '--check', '--no-sync', '--jobs=2',
+	'--old-datadir' => $oldnode->data_dir,
+	'--new-datadir' => $newnode->data_dir,
+	'--old-bindir' => $oldnode->config_data('--bindir'),
+	'--new-bindir' => $newnode->config_data('--bindir'),
+	'--old-port' => $oldnode->port,
+	'--new-port' => $newnode->port);
+my @checks;
+for my $host ('127.0.0.1', 'localhost')
+{
+	my ($stdout, $stderr) = ('', '');
+	my ($harness, $error, $status);
+	{
+		# IPv4 is the successful-first control; localhost must fall back.
+		local $ENV{PGHOST} = $host;
+		local $ENV{PGHOSTADDR};
+		delete $ENV{PGHOSTADDR};
+		my $timer =
+		  IPC::Run::timeout($PostgreSQL::Test::Utils::timeout_default);
+		eval {
+			$harness = IPC::Run::start(
+				\@command,
+				'>' => \$stdout,
+				'2>' => \$stderr,
+				$timer);
+			$harness->finish;
+			$status = $?;
+		};
+		$error = $@;
+		$harness->kill_kill if $error && defined $harness;
+	}
+	push @checks, [ $host, $stdout, $stderr, $error, $status ];
+	last if $error || !defined $status || $status;
+}
+
+# Cleanup precedes assertions and includes a server left after a timeout.
+$newnode->_update_pid(-1);
+$newnode->stop;
+$oldnode->stop;
+
+for my $check (@checks)
+{
+	my ($host, $stdout, $stderr, $error, $status) = @$check;
+	ok( !$error && defined $status && $status == 0,
+		"pg_upgrade --check completes with host=$host");
+	like(
+		$stdout,
+		qr/Clusters are compatible/,
+		"pg_upgrade checks both clusters with host=$host");
+	diag("$error\n$stdout\n$stderr") if $error || !defined $status || $status;
+}
+is(scalar @checks, 2, 'both successful-first and refused-first cases ran');
+ok(!-e $oldnode->data_dir . '/postmaster.pid', 'old server is stopped');
+ok(!-e $newnode->data_dir . '/postmaster.pid', 'new server is stopped');
+done_testing();
diff --git a/src/bin/pg_upgrade/task.c b/src/bin/pg_upgrade/task.c
index b6eb29e1f3a..076308e9326 100644
--- a/src/bin/pg_upgrade/task.c
+++ b/src/bin/pg_upgrade/task.c
@@ -333,6 +333,9 @@ select_loop(int maxFd, fd_set *input, fd_set *output)
 {
 	fd_set		save_input = *input;
 	fd_set		save_output = *output;
+#ifdef WIN32
+	fd_set		exceptions;
+#endif
 
 	if (maxFd == 0)
 		return 0;
@@ -344,7 +347,18 @@ select_loop(int maxFd, fd_set *input, fd_set *output)
 		*input = save_input;
 		*output = save_output;
 
+#ifdef WIN32
+		/* Winsock reports failed nonblocking connects as exceptions. */
+		exceptions = save_output;
+		i = select(maxFd + 1, input, output, &exceptions, NULL);
+		if (i > 0)
+		{
+			for (unsigned int j = 0; j < exceptions.fd_count; j++)
+				FD_SET(exceptions.fd_array[j], output);
+		}
+#else
 		i = select(maxFd + 1, input, output, NULL, NULL);
+#endif
 
 #ifndef WIN32
 		if (i < 0 && errno == EINTR)

Reply via email to