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)
