4
4
import os
5
5
import os .path
6
6
import posixpath
7
+ import signal
7
8
import subprocess
8
9
import sys
9
10
import time
@@ -97,6 +98,46 @@ def noop(_: str):
97
98
pass
98
99
99
100
101
+ class TerminationSignal (RuntimeError ): # noqa: N818
102
+ def __init__ (self , signal ):
103
+ self .signal = signal
104
+ super ().__init__ ("Received termination signal" , signal )
105
+
106
+ def __repr__ (self ):
107
+ return f"{ self .__class__ .__name__ } ({ self .signal } )"
108
+
109
+
110
+ if sys .platform == "win32" :
111
+ SIGINT = signal .CTRL_C_EVENT
112
+ else :
113
+ SIGINT = signal .SIGINT
114
+
115
+
116
+ def shutdown_process (
117
+ proc : subprocess .Popen ,
118
+ interrupt_timeout : Optional [int ] = None ,
119
+ terminate_timeout : Optional [int ] = None ,
120
+ ) -> None :
121
+ """Shut down the process gracefully with SIGINT -> SIGTERM -> SIGKILL."""
122
+
123
+ logger .info ("sending interrupt signal to the process %s" , proc .pid )
124
+ proc .send_signal (SIGINT )
125
+
126
+ logger .info ("waiting for the process %s to finish" , proc .pid )
127
+ try :
128
+ proc .wait (interrupt_timeout )
129
+ except subprocess .TimeoutExpired :
130
+ logger .info (
131
+ "timed out waiting, sending terminate signal to the process %s" , proc .pid
132
+ )
133
+ proc .terminate ()
134
+ try :
135
+ proc .wait (terminate_timeout )
136
+ except subprocess .TimeoutExpired :
137
+ logger .info ("timed out waiting, killing the process %s" , proc .pid )
138
+ proc .kill ()
139
+
140
+
100
141
def _process_stream (stream : "IO[bytes]" , callback : Callable [[str ], None ]) -> None :
101
142
buffer = b""
102
143
while byt := stream .read (1 ): # Read one byte at a time
@@ -1493,6 +1534,8 @@ def query(
1493
1534
output_hook : Callable [[str ], None ] = noop ,
1494
1535
params : Optional [dict [str , str ]] = None ,
1495
1536
job_id : Optional [str ] = None ,
1537
+ interrupt_timeout : Optional [int ] = None ,
1538
+ terminate_timeout : Optional [int ] = None ,
1496
1539
) -> None :
1497
1540
cmd = [python_executable , "-c" , query_script ]
1498
1541
env = dict (env or os .environ )
@@ -1506,13 +1549,42 @@ def query(
1506
1549
if capture_output :
1507
1550
popen_kwargs = {"stdout" : subprocess .PIPE , "stderr" : subprocess .STDOUT }
1508
1551
1509
- with subprocess .Popen (cmd , env = env , ** popen_kwargs ) as proc : # noqa: S603
1510
- if capture_output :
1511
- args = (proc .stdout , output_hook )
1512
- thread = Thread (target = _process_stream , args = args , daemon = True )
1513
- thread .start ()
1514
- thread .join () # wait for the reader thread
1552
+ def signal_handler (sig : int , _ : Any ) -> NoReturn :
1553
+ raise TerminationSignal (sig )
1554
+
1555
+ original_sigterm_handler = signal .getsignal (signal .SIGTERM )
1556
+ signal .signal (signal .SIGTERM , signal_handler )
1515
1557
1558
+ with subprocess .Popen (cmd , env = env , ** popen_kwargs ) as proc : # noqa: S603
1559
+ logger .info ("Starting process %s" , proc .pid )
1560
+ thread : Optional [Thread ] = None
1561
+ try :
1562
+ if capture_output :
1563
+ args = (proc .stdout , output_hook )
1564
+ thread = Thread (target = _process_stream , args = args , daemon = True )
1565
+ thread .start ()
1566
+
1567
+ proc .wait ()
1568
+ except (KeyboardInterrupt , TerminationSignal ) as exc :
1569
+ # NOTE: we ignore `Ctrl-C` signals on CLI so `KeyboardInterrupt`
1570
+ # is not expected to be raised here, but we handle it just in case.
1571
+ # If we don't ignore it in CLI, the child will receive the signal twice.
1572
+ logging .info ("Shutting down process %s, received %r" , proc .pid , exc )
1573
+ # Rather than forwarding the signal to the child, we try to shut it down
1574
+ # gracefully. This is because we consider the script to be interactive
1575
+ # and special, so we give it time to cleanup before exiting.
1576
+ shutdown_process (proc , interrupt_timeout , terminate_timeout )
1577
+ if proc .returncode :
1578
+ raise QueryScriptCancelError (
1579
+ "Query script was canceled by user" , return_code = proc .returncode
1580
+ ) from exc
1581
+ finally :
1582
+ if original_sigterm_handler :
1583
+ signal .signal (signal .SIGTERM , original_sigterm_handler )
1584
+ if thread :
1585
+ thread .join ()
1586
+
1587
+ logging .info ("Process %s exited with return code %s" , proc .pid , proc .returncode )
1516
1588
if proc .returncode == QUERY_SCRIPT_CANCELED_EXIT_CODE :
1517
1589
raise QueryScriptCancelError (
1518
1590
"Query script was canceled by user" ,
0 commit comments