Skip to content

Commit afba537

Browse files
committed
fix: Bound shutdown when an RPC handler is blocked
1 parent dd5cfcf commit afba537

2 files changed

Lines changed: 84 additions & 6 deletions

File tree

‎cloudquery/sdk/serve/plugin.py‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import subprocess
99
import tarfile
1010
import threading
11+
import time
1112
from concurrent import futures
1213
from pathlib import Path
1314

@@ -26,6 +27,8 @@
2627
from cloudquery.sdk.schema import table
2728

2829
_IS_WINDOWS = sys.platform == "win32"
30+
_STOP_GRACE_SECONDS = 5
31+
_FORCED_EXIT_DELAY_SECONDS = 5
2932

3033
try:
3134
import colorama
@@ -409,11 +412,26 @@ def _serve(self, args):
409412
def _stop_on_termination_signals(self):
410413
if threading.current_thread() is not threading.main_thread():
411414
return
415+
self._termination_requested = False
412416
for signum in (signal.SIGTERM, signal.SIGINT):
413-
signal.signal(signum, self._stop_in_background)
417+
signal.signal(signum, self._on_termination_signal)
414418

415-
def _stop_in_background(self, signum, frame):
416-
threading.Thread(target=self.stop, daemon=True).start()
419+
def _on_termination_signal(self, signum, frame):
420+
if self._termination_requested:
421+
_exit_now()
422+
self._termination_requested = True
423+
threading.Thread(target=self._stop_then_force_exit, daemon=True).start()
424+
425+
def _stop_then_force_exit(self):
426+
self.stop().wait()
427+
time.sleep(_FORCED_EXIT_DELAY_SECONDS)
428+
_exit_now()
417429

418430
def stop(self):
419-
self._server.stop(5)
431+
return self._server.stop(_STOP_GRACE_SECONDS)
432+
433+
434+
def _exit_now():
435+
sys.stdout.flush()
436+
sys.stderr.flush()
437+
os._exit(0)

‎tests/serve/signals.py‎

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
import signal
2+
import socket
23
import subprocess
34
import sys
45
import textwrap
56
import time
67

8+
import grpc
79
import pytest
810

11+
from cloudquery.plugin_v3 import plugin_pb2, plugin_pb2_grpc
12+
913
SERVE_PLUGIN = """
1014
from cloudquery.sdk import serve
1115
from cloudquery.sdk.plugin import Plugin
1216
1317
serve.PluginCommand(Plugin("test", "v0.0.1")).run(["serve", "--address", "127.0.0.1:0"])
1418
"""
1519

20+
SERVE_PLUGIN_WITH_BLOCKED_INIT = """
21+
import sys, threading
22+
from cloudquery.sdk import serve
23+
from cloudquery.sdk.plugin import Plugin
24+
25+
class BlockedInitPlugin(Plugin):
26+
def init(self, spec, no_connection=False):
27+
threading.Event().wait()
28+
29+
serve.PluginCommand(BlockedInitPlugin("test", "v0.0.1")).run(["serve", "--address", sys.argv[1]])
30+
"""
31+
1632
SIGTERM_DURING_LOCK_RESTORE = """
1733
import os, signal, sys, threading
1834
from cloudquery.sdk import serve
@@ -38,11 +54,11 @@ def _acquire_restore(self, x):
3854
"""
3955

4056

41-
def start_plugin(tmp_path, source):
57+
def start_plugin(tmp_path, source, *args):
4258
script = tmp_path / "plugin_main.py"
4359
script.write_text(textwrap.dedent(source))
4460
return subprocess.Popen(
45-
[sys.executable, str(script)],
61+
[sys.executable, str(script), *args],
4662
stdout=subprocess.PIPE,
4763
stderr=subprocess.STDOUT,
4864
text=True,
@@ -70,3 +86,47 @@ def test_signal_while_serving_exits_zero(tmp_path, signum):
7086
process.send_signal(signum)
7187
output, _ = process.communicate(timeout=30)
7288
assert process.returncode == 0, output
89+
90+
91+
def free_address():
92+
with socket.socket() as sock:
93+
sock.bind(("127.0.0.1", 0))
94+
return "127.0.0.1:%d" % sock.getsockname()[1]
95+
96+
97+
def start_plugin_with_blocked_init(tmp_path):
98+
address = free_address()
99+
process = start_plugin(tmp_path, SERVE_PLUGIN_WITH_BLOCKED_INIT, address)
100+
wait_until_serving(process)
101+
channel = grpc.insecure_channel(address)
102+
init_call = plugin_pb2_grpc.PluginStub(channel).Init.future(
103+
plugin_pb2.Init.Request(spec=b"")
104+
)
105+
time.sleep(0.5)
106+
return process, channel, init_call
107+
108+
109+
def test_sigterm_with_blocked_handler_exits_zero_within_bound(tmp_path):
110+
process, channel, init_call = start_plugin_with_blocked_init(tmp_path)
111+
try:
112+
process.send_signal(signal.SIGTERM)
113+
output, _ = process.communicate(timeout=20)
114+
assert process.returncode == 0, output
115+
finally:
116+
init_call.cancel()
117+
channel.close()
118+
process.kill()
119+
120+
121+
def test_second_signal_with_blocked_handler_exits_zero_immediately(tmp_path):
122+
process, channel, init_call = start_plugin_with_blocked_init(tmp_path)
123+
try:
124+
process.send_signal(signal.SIGTERM)
125+
time.sleep(0.5)
126+
process.send_signal(signal.SIGINT)
127+
output, _ = process.communicate(timeout=3)
128+
assert process.returncode == 0, output
129+
finally:
130+
init_call.cancel()
131+
channel.close()
132+
process.kill()

0 commit comments

Comments
 (0)