1+ # Copyright (c) 2025, PostgreSQL Global Development Group
2+
3+ # Test that socket handles are not inherited by child processes on Windows.
4+ #
5+ # Without the fix, child processes spawned via COPY TO PROGRAM inherit socket
6+ # handles from the backend. Windows reference counting prevents these sockets
7+ # from being freed when the postmaster exits, leaving the port bound to a dead
8+ # process (a "zombie" binding). This test verifies that after killing the
9+ # postmaster while a child process is still running, the listening port is
10+ # immediately freed rather than remaining in a zombie state.
11+
12+ use strict;
13+ use warnings;
14+
15+ use PostgreSQL::Test::Cluster;
16+ use PostgreSQL::Test::Utils;
17+ use Test::More;
18+ use Time::HiRes qw( sleep) ;
19+
20+ # This test is Windows-specific
21+ if ($^O ne ' MSWin32' )
22+ {
23+ plan skip_all => ' test is specific to Windows socket handle inheritance' ;
24+ }
25+
26+ my $node = PostgreSQL::Test::Cluster-> new(' main' );
27+ $node -> init;
28+ $node -> start;
29+
30+ # Get the port number for verification
31+ my $port = $node -> port;
32+
33+ # Spawn a long-running child process via COPY TO PROGRAM that will outlive
34+ # the postmaster. Without the fix, this child inherits socket handles.
35+ my $marker_file = $node -> data_dir . ' /ps_marker.txt' ;
36+ unlink $marker_file if -e $marker_file ;
37+
38+ $node -> safe_psql(
39+ ' postgres' ,
40+ qq{ \\ copy (select 1) to program 'powershell -Command "echo marker > $marker_file ; Start-Sleep 120"'}
41+ );
42+
43+ # Wait for PowerShell to spawn
44+ my $ps_spawned = 0;
45+ for (my $i = 0; $i < 100; $i ++)
46+ {
47+ if (-e $marker_file )
48+ {
49+ $ps_spawned = 1;
50+ last ;
51+ }
52+ sleep 0.1;
53+ }
54+
55+ ok($ps_spawned , ' child process spawned successfully' );
56+
57+ # Stop the postmaster (simulates a crash), leaving the child process running.
58+ $node -> stop(' immediate' );
59+ sleep 0.5;
60+
61+ # Verify that the listening port is freed immediately. With the bug, the port
62+ # remains bound to the dead postmaster PID because the child process inherited
63+ # the socket handles. With the fix, the port is freed because socket handles
64+ # were not inherited.
65+ my $netstat_output = ` netstat -ano | findstr ":$port .*LISTENING"` ;
66+
67+ if ($netstat_output )
68+ {
69+ fail(' listening port remains bound after postmaster exit (zombie port)' );
70+ diag(" Port is still bound - socket handles were inherited by child process" );
71+ diag(" netstat output:\n $netstat_output " );
72+
73+ if ($netstat_output =~ / LISTENING\s +(\d +)/ )
74+ {
75+ my $bound_pid = $1 ;
76+ my $process_name = get_process_name($bound_pid );
77+
78+ if ($process_name eq ' unknown' || $process_name eq ' ' )
79+ {
80+ diag(" Port bound to dead process (PID $bound_pid ) - zombie binding detected" );
81+ }
82+ else
83+ {
84+ diag(" Port bound to: $process_name (PID $bound_pid )" );
85+ }
86+ }
87+ }
88+ else
89+ {
90+ pass(' listening port freed immediately after postmaster exit' );
91+ }
92+
93+ # Additional verification: Confirm the port is actually available for binding.
94+ # This tests the real-world scenario that matters to users.
95+ my $can_bind = test_port_available($port );
96+ ok($can_bind , " port $port is available for new connections" );
97+
98+ # Cleanup
99+ cleanup_powershell_processes();
100+ unlink $marker_file if -e $marker_file ;
101+
102+ done_testing();
103+
104+ # Test if port can actually be bound
105+ sub test_port_available
106+ {
107+ my ($port ) = @_ ;
108+
109+ use Socket;
110+
111+ socket (my $sock , PF_INET, SOCK_STREAM, getprotobyname (' tcp' )) or return 0;
112+ setsockopt ($sock , SOL_SOCKET, SO_REUSEADDR, 1);
113+
114+ my $addr = sockaddr_in($port , INADDR_ANY);
115+ my $result = bind ($sock , $addr );
116+ close ($sock );
117+
118+ return $result ? 1 : 0;
119+ }
120+
121+ # Get process name by PID
122+ sub get_process_name
123+ {
124+ my ($pid ) = @_ ;
125+ my $name = ` powershell -Command "(Get-Process -Id $pid -ErrorAction SilentlyContinue).ProcessName" 2>nul` ;
126+ chomp $name ;
127+ return $name || ' unknown' ;
128+ }
129+
130+ # Clean up test child processes
131+ sub cleanup_powershell_processes
132+ {
133+ system (' powershell -Command "Get-Process powershell -ErrorAction SilentlyContinue | Where-Object {$_.Id -ne $PID} | Stop-Process -Force -ErrorAction SilentlyContinue" 2>nul' );
134+ }
0 commit comments