-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathmain.py
More file actions
193 lines (157 loc) · 6.13 KB
/
main.py
File metadata and controls
193 lines (157 loc) · 6.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
#!/usr/bin/env python3
# main.py - Sapphire Runner
# Manages sapphire.py lifecycle with restart support
#
# Exit codes from sapphire.py:
# 0 = Clean shutdown
# 42 = Restart requested
# * = Crash/error
#
# Signal exit codes (treated as clean shutdown):
# -2, 130 = SIGINT (Ctrl+C)
# -15, 143 = SIGTERM
# -1, 129 = SIGHUP
import sys
import subprocess
import signal
import time
import argparse
from pathlib import Path
IS_WINDOWS = sys.platform == 'win32'
# ANSI colors for terminal output
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
RESET = '\033[0m'
# Signal state
_runner_stopping = False
_child_process = None
# Exit codes that mean "user requested stop" not "crash"
CLEAN_EXIT_CODES = {
0, # Normal exit
-2, 130, # SIGINT (Ctrl+C) - negative on Unix, 128+sig on some systems
-15, 143, # SIGTERM
-1, 129, # SIGHUP
3221225786, # STATUS_CONTROL_C_EXIT (0xC000013A) - Windows Ctrl+C termination
}
def log(msg, color=None):
"""Print with optional color."""
if IS_WINDOWS:
# Windows doesn't handle ANSI well in all terminals
print(f"[Runner] {msg}")
else:
prefix = f"{color}" if color else ""
suffix = f"{RESET}" if color else ""
print(f"{prefix}[Runner] {msg}{suffix}")
def handle_signal(signum, frame):
"""Handle termination signals - mark stopping and forward to child."""
global _runner_stopping
_runner_stopping = True
if _child_process and _child_process.poll() is None:
try:
if IS_WINDOWS:
# On Windows, Ctrl+C already sent CTRL_C_EVENT to entire console group.
# Calling send_signal(SIGINT) would re-send CTRL_C_EVENT to the group
# (including ourselves), causing double delivery. Skip it.
pass
else:
_child_process.send_signal(signum)
except (ProcessLookupError, OSError):
pass # Child already dead
def run_sapphire():
"""Run sapphire.py and return its exit code."""
global _child_process
script_path = Path(__file__).parent / "sapphire.py"
if not script_path.exists():
log(f"ERROR: sapphire.py not found at {script_path}", RED)
return 1
try:
_child_process = subprocess.Popen(
[sys.executable, str(script_path)],
stdin=sys.stdin,
stdout=sys.stdout,
stderr=sys.stderr
)
# Wait indefinitely - signals are forwarded via handle_signal(), so Ctrl+C
# will reach the child. No timeout/watchdog needed; if child hangs, user
# can still Ctrl+C which sets _runner_stopping and forwards SIGINT.
_child_process.wait()
exit_code = _child_process.returncode
_child_process = None
return exit_code
except KeyboardInterrupt:
# Windows: KeyboardInterrupt can bypass custom signal handler during wait()
if _child_process and _child_process.poll() is None:
try:
_child_process.wait(timeout=15)
except (subprocess.TimeoutExpired, KeyboardInterrupt):
try:
_child_process.kill()
except OSError:
pass
_child_process = None
return 0 # Treat Ctrl+C as clean exit
except Exception as e:
log(f"ERROR: Failed to run sapphire.py: {e}", RED)
_child_process = None
return 1
def main():
global _runner_stopping
parser = argparse.ArgumentParser(description='Sapphire Voice Assistant Runner')
parser.add_argument('--once', action='store_true',
help='Run once without restart loop (for debugging)')
parser.add_argument('--max-crashes', type=int, default=5,
help='Max consecutive crashes before giving up (default: 5)')
args = parser.parse_args()
# Register signal handlers
signal.signal(signal.SIGINT, handle_signal)
if hasattr(signal, 'SIGTERM'):
signal.signal(signal.SIGTERM, handle_signal)
if hasattr(signal, 'SIGHUP'):
signal.signal(signal.SIGHUP, handle_signal)
log("Sapphire Runner starting", GREEN)
if args.once:
log("Running in single-run mode (--once)", YELLOW)
exit_code = run_sapphire()
log(f"Exited with code {exit_code}")
sys.exit(0 if exit_code in CLEAN_EXIT_CODES or exit_code == 42 else exit_code)
consecutive_crashes = 0
backoff_seconds = 2
while True:
_runner_stopping = False
exit_code = run_sapphire()
# Check if runner itself was signaled to stop
if _runner_stopping:
log("Interrupted, exiting", YELLOW)
sys.exit(0)
# Check for clean exit codes (including signal-based exits)
if exit_code in CLEAN_EXIT_CODES:
log("Clean shutdown, exiting runner", GREEN)
sys.exit(0)
# Restart requested
if exit_code == 42:
log("Restart requested, restarting in 1 second...", YELLOW)
consecutive_crashes = 0
time.sleep(1)
# Double-check we weren't interrupted during sleep
if _runner_stopping:
log("Interrupted during restart delay, exiting", YELLOW)
sys.exit(0)
continue
# Crash or error
consecutive_crashes += 1
log(f"Crashed with exit code {exit_code} (crash {consecutive_crashes}/{args.max_crashes})", RED)
if consecutive_crashes >= args.max_crashes:
log(f"Too many consecutive crashes, giving up", RED)
sys.exit(1)
# Exponential backoff: 2, 4, 8, 16, 32 seconds (capped)
wait_time = min(backoff_seconds * (2 ** (consecutive_crashes - 1)), 32)
log(f"Restarting in {wait_time} seconds... (Ctrl+C to exit)", YELLOW)
# Sleep in small increments so Ctrl+C is responsive
for _ in range(wait_time * 10):
if _runner_stopping:
log("Interrupted during backoff, exiting", YELLOW)
sys.exit(0)
time.sleep(0.1)
if __name__ == "__main__":
main()