Skip to content

Commit 74d35d3

Browse files
committed
Server: refine shutdown logic per bot feedback\n- Parameterize _force_exit(code) and use timers with args\n- Consistent behavior on BrokenPipeError (no immediate exit)\n- Exit code 1 on unexpected exceptions\n\nTests: restore telemetry module after disabling to avoid bleed-over
1 parent 00fad91 commit 74d35d3

File tree

2 files changed

+110
-27
lines changed

2 files changed

+110
-27
lines changed

Server/server.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
from logging.handlers import RotatingFileHandler
55
import os
66
from contextlib import asynccontextmanager
7+
import sys
8+
import signal
9+
import threading
710
from typing import AsyncIterator, Dict, Any
811
from config import config
912
from tools import register_all_tools
@@ -64,6 +67,9 @@
6467
# Global connection state
6568
_unity_connection: UnityConnection = None
6669

70+
# Global shutdown coordination
71+
_shutdown_flag = threading.Event()
72+
6773

6874
@asynccontextmanager
6975
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
@@ -186,9 +192,96 @@ def _emit_startup():
186192
register_all_resources(mcp)
187193

188194

195+
def _force_exit(code: int = 0):
196+
"""Force process exit, bypassing any background threads that might linger."""
197+
try:
198+
sys.exit(code)
199+
except SystemExit:
200+
os._exit(code)
201+
202+
203+
def _signal_handler(signum, frame):
204+
logger.info(f"Received signal {signum}, initiating shutdown...")
205+
_shutdown_flag.set()
206+
threading.Timer(1.0, _force_exit, args=(0,)).start()
207+
208+
209+
def _monitor_stdin():
210+
"""Background thread to detect stdio detach (stdin EOF) or parent exit."""
211+
try:
212+
parent_pid = os.getppid() if hasattr(os, "getppid") else None
213+
while not _shutdown_flag.is_set():
214+
if _shutdown_flag.wait(0.5):
215+
break
216+
217+
if parent_pid is not None:
218+
try:
219+
os.kill(parent_pid, 0)
220+
except (ProcessLookupError, OSError):
221+
logger.info(f"Parent process {parent_pid} no longer exists; shutting down")
222+
break
223+
224+
try:
225+
if sys.stdin.closed:
226+
logger.info("stdin.closed is True; client disconnected")
227+
break
228+
fd = sys.stdin.fileno()
229+
if fd < 0:
230+
logger.info("stdin fd invalid; client disconnected")
231+
break
232+
except (ValueError, OSError, AttributeError):
233+
# Closed pipe or unavailable stdin
234+
break
235+
except Exception:
236+
# Ignore transient errors
237+
pass
238+
239+
if not _shutdown_flag.is_set():
240+
logger.info("Client disconnected (stdin or parent), initiating shutdown...")
241+
_shutdown_flag.set()
242+
if not _shutdown_flag.is_set():
243+
threading.Timer(0.5, _force_exit, args=(0,)).start()
244+
else:
245+
threading.Timer(0.5, _force_exit, args=(0,)).start()
246+
except Exception:
247+
# Never let monitor thread crash the process
248+
pass
249+
250+
189251
def main():
190252
"""Entry point for uvx and console scripts."""
191-
mcp.run(transport='stdio')
253+
try:
254+
signal.signal(signal.SIGTERM, _signal_handler)
255+
signal.signal(signal.SIGINT, _signal_handler)
256+
if hasattr(signal, "SIGPIPE"):
257+
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
258+
if hasattr(signal, "SIGBREAK"):
259+
signal.signal(signal.SIGBREAK, _signal_handler)
260+
except Exception:
261+
# Signals can fail in some environments
262+
pass
263+
264+
t = threading.Thread(target=_monitor_stdin, daemon=True)
265+
t.start()
266+
267+
try:
268+
mcp.run(transport='stdio')
269+
logger.info("FastMCP run() returned (stdin EOF or disconnect)")
270+
except (KeyboardInterrupt, SystemExit):
271+
logger.info("Server interrupted; shutting down")
272+
_shutdown_flag.set()
273+
except BrokenPipeError:
274+
logger.info("Broken pipe; shutting down")
275+
_shutdown_flag.set()
276+
# rely on finally to schedule exit for consistency
277+
except Exception as e:
278+
logger.error(f"Server error: {e}", exc_info=True)
279+
_shutdown_flag.set()
280+
_force_exit(1)
281+
finally:
282+
_shutdown_flag.set()
283+
logger.info("Server main loop exited")
284+
threading.Timer(0.5, _force_exit, args=(0,)).start()
192285

193286

194287
# Run the server

Server/test_telemetry.py

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ def test_telemetry_basic():
2323
)
2424
pass
2525
except ImportError as e:
26-
# Silent failure path for tests
27-
return False
26+
# Fail explicitly when imports are missing
27+
assert False, f"telemetry import failed: {e}"
2828

2929
# Test telemetry enabled status
3030
_ = is_telemetry_enabled()
@@ -37,8 +37,7 @@ def test_telemetry_basic():
3737
})
3838
pass
3939
except Exception as e:
40-
# Silent failure path for tests
41-
return False
40+
assert False, f"record_telemetry failed: {e}"
4241

4342
# Test milestone recording
4443
try:
@@ -47,26 +46,23 @@ def test_telemetry_basic():
4746
})
4847
_ = is_first
4948
except Exception as e:
50-
# Silent failure path for tests
51-
return False
49+
assert False, f"record_milestone failed: {e}"
5250

5351
# Test telemetry collector
5452
try:
5553
collector = get_telemetry()
5654
_ = collector
5755
except Exception as e:
58-
# Silent failure path for tests
59-
return False
56+
assert False, f"get_telemetry failed: {e}"
57+
assert True
6058

61-
return True
6259

63-
64-
def test_telemetry_disabled():
60+
def test_telemetry_disabled(monkeypatch):
6561
"""Test telemetry with disabled state"""
6662
# Silent for tests
6763

6864
# Set environment variable to disable telemetry
69-
os.environ["DISABLE_TELEMETRY"] = "true"
65+
monkeypatch.setenv("DISABLE_TELEMETRY", "true")
7066

7167
# Re-import to get fresh config
7268
import importlib
@@ -77,17 +73,12 @@ def test_telemetry_disabled():
7773

7874
_ = is_telemetry_enabled()
7975

80-
if not is_telemetry_enabled():
81-
pass
82-
83-
# Test that records are ignored when disabled
84-
record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"})
85-
pass
86-
87-
return True
88-
else:
89-
pass
90-
return False
76+
assert is_telemetry_enabled() is False
77+
# Test that records are ignored when disabled (should not raise)
78+
record_telemetry(RecordType.USAGE, {"test": "should_be_ignored"})
79+
# Restore module state for subsequent tests
80+
monkeypatch.delenv("DISABLE_TELEMETRY", raising=False)
81+
importlib.reload(telemetry)
9182

9283

9384
def test_data_storage():
@@ -114,11 +105,10 @@ def test_data_storage():
114105
else:
115106
pass
116107

117-
return True
108+
assert True
118109

119110
except Exception as e:
120-
# Silent failure path for tests
121-
return False
111+
assert False, f"data storage test failed: {e}"
122112

123113

124114
def main():

0 commit comments

Comments
 (0)