Skip to content

Commit a6af899

Browse files
committed
Fix infinite hang in wait_for_process_to_die() on Windows
The ninja generator uses wait_for_process_to_die() to wait for a previous scons daemon process to terminate. It previously had 3 separate implemementations: one using psutil if that module was not available, plus a fallback implementation for Windows, and one for non-Windows platforms. I was encountering problems with the fallback implementation on Windows hanging forever, and not being interruptible even with Ctrl-C. Apparently the win32 OpenProcess() function can return a valid process handle even for already terminated processes. This change adds an extra call to GetExitCodeProcess() to avoid infinitely looping if the process has in fact already exited. I also added a timeout so that this function will eventually fail rather than hanging forever if a process does not exit. I also removed the psutil implementation: it seemed simpler to me to avoid having multiple separate implementations with different behaviors. This appeared to be the only place in scons that depends on psutil outside of tests. Also note that this wait_for_process_to_die() function is only used by the ninja tool. I have added unit tests checking the behavior of wait_for_process_to_die(). Without the `GetExitCodeProcess()` check added by this PR, the simple code in `test_wait_for_process_to_die_success()` would hang forever.
1 parent a6abb0f commit a6af899

File tree

3 files changed

+73
-31
lines changed

3 files changed

+73
-31
lines changed

SCons/Tool/ninja_tool/NinjaState.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,8 @@ def check_generated_source_deps(build):
694694
pass
695695

696696
# wait for the server process to fully killed
697-
# TODO: update wait_for_process_to_die() to handle timeout and then catch exception
698-
# here and do something smart.
699-
wait_for_process_to_die(pid)
697+
# TODO: catch TimeoutException here and possibly do something smart.
698+
wait_for_process_to_die(pid, timeout=10)
700699

701700
if os.path.exists(scons_daemon_dirty):
702701
os.unlink(scons_daemon_dirty)

SCons/Util/UtilTests.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import hashlib
2828
import io
2929
import os
30+
import subprocess
3031
import sys
3132
import unittest
3233
import unittest.mock
@@ -73,6 +74,7 @@
7374
to_String,
7475
to_bytes,
7576
to_str,
77+
wait_for_process_to_die,
7678
)
7779
from SCons.Util.envs import is_valid_construction_var
7880
from SCons.Util.hashes import (
@@ -847,6 +849,27 @@ def test_intern(self) -> None:
847849
s4 = silent_intern("spam")
848850
assert id(s1) == id(s4)
849851

852+
def test_wait_for_process_to_die_success(self) -> None:
853+
cmd = [sys.executable, "-c", ""]
854+
p = subprocess.Popen(cmd)
855+
p.wait()
856+
wait_for_process_to_die(p.pid, timeout=10.0)
857+
858+
def test_wait_for_process_to_die_timeout(self) -> None:
859+
# Run a python script that will keep running until we close it's stdin
860+
cmd = [sys.executable, "-c", "import sys; data = sys.stdin.read()"]
861+
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
862+
863+
# wait_for_process_to_die() should time out while the process is running
864+
with self.assertRaises(TimeoutError):
865+
wait_for_process_to_die(p.pid, timeout=0.2)
866+
867+
p.stdin.close()
868+
p.wait()
869+
870+
# wait_for_process_to_die() should complete normally now
871+
wait_for_process_to_die(p.pid, timeout=10.0)
872+
850873

851874
class HashTestCase(unittest.TestCase):
852875

SCons/Util/__init__.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,36 +1297,56 @@ def print_time():
12971297
return print_time
12981298

12991299

1300-
def wait_for_process_to_die(pid) -> None:
1300+
def wait_for_process_to_die(pid: int, timeout: float = 60.0) -> None:
13011301
"""
1302-
Wait for specified process to die, or alternatively kill it
1303-
NOTE: This function operates best with psutil pypi package
1304-
TODO: Add timeout which raises exception
1302+
Wait for the specified process to die.
1303+
1304+
A TimeoutError will be thrown if the timeout is hit before the process terminates.
1305+
Specifying a negative timeout will cause wait_for_process_to_die() to wait
1306+
indefinitely, and never throw TimeoutError.
13051307
"""
1306-
# wait for the process to fully killed
1307-
try:
1308-
import psutil # pylint: disable=import-outside-toplevel
1309-
while True:
1310-
if pid not in [proc.pid for proc in psutil.process_iter()]:
1311-
break
1312-
time.sleep(0.1)
1313-
except ImportError:
1314-
# if psutil is not installed we can do this the hard way
1315-
while True:
1316-
if sys.platform == 'win32':
1317-
import ctypes # pylint: disable=import-outside-toplevel
1318-
PROCESS_QUERY_INFORMATION = 0x1000
1319-
processHandle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid)
1320-
if processHandle == 0:
1321-
break
1322-
ctypes.windll.kernel32.CloseHandle(processHandle)
1323-
time.sleep(0.1)
1324-
else:
1325-
try:
1326-
os.kill(pid, 0)
1327-
except OSError:
1328-
break
1329-
time.sleep(0.1)
1308+
start_time = time.time()
1309+
while True:
1310+
if not _is_process_alive(pid):
1311+
break
1312+
if timeout >= 0.0 and time.time() - start_time > timeout:
1313+
raise TimeoutError(f"timed out waiting for process {pid}")
1314+
time.sleep(0.1)
1315+
1316+
1317+
if sys.platform == 'win32':
1318+
def _is_process_alive(pid: int) -> bool:
1319+
import ctypes # pylint: disable=import-outside-toplevel
1320+
PROCESS_QUERY_INFORMATION = 0x1000
1321+
STILL_ACTIVE = 259
1322+
1323+
processHandle = ctypes.windll.kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid)
1324+
if processHandle == 0:
1325+
return False
1326+
1327+
# OpenProcess() may successfully return a handle even for terminated
1328+
# processes when something else in the system is still holding a
1329+
# reference to their handle. Call GetExitCodeProcess() to check if the
1330+
# process has already exited.
1331+
try:
1332+
exit_code = ctypes.c_ulong()
1333+
success = ctypes.windll.kernel32.GetExitCodeProcess(
1334+
processHandle, ctypes.byref(exit_code))
1335+
if success:
1336+
return exit_code.value == STILL_ACTIVE
1337+
finally:
1338+
ctypes.windll.kernel32.CloseHandle(processHandle)
1339+
1340+
return True
1341+
1342+
else:
1343+
def _is_process_alive(pid: int) -> bool:
1344+
try:
1345+
os.kill(pid, 0)
1346+
return True
1347+
except OSError:
1348+
return False
1349+
13301350

13311351
# From: https://stackoverflow.com/questions/1741972/how-to-use-different-formatters-with-the-same-logging-handler-in-python
13321352
class DispatchingFormatter(Formatter):

0 commit comments

Comments
 (0)