fbuild 1.2.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fbuild/__init__.py +390 -0
- fbuild/assets/example.txt +1 -0
- fbuild/build/__init__.py +117 -0
- fbuild/build/archive_creator.py +186 -0
- fbuild/build/binary_generator.py +444 -0
- fbuild/build/build_component_factory.py +131 -0
- fbuild/build/build_info_generator.py +624 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +93 -0
- fbuild/build/compilation_executor.py +422 -0
- fbuild/build/compiler.py +165 -0
- fbuild/build/compiler_avr.py +574 -0
- fbuild/build/configurable_compiler.py +664 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +214 -0
- fbuild/build/library_dependency_processor.py +185 -0
- fbuild/build/linker.py +708 -0
- fbuild/build/orchestrator.py +67 -0
- fbuild/build/orchestrator_avr.py +651 -0
- fbuild/build/orchestrator_esp32.py +878 -0
- fbuild/build/orchestrator_rp2040.py +719 -0
- fbuild/build/orchestrator_stm32.py +696 -0
- fbuild/build/orchestrator_teensy.py +580 -0
- fbuild/build/source_compilation_orchestrator.py +218 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +717 -0
- fbuild/cli_utils.py +314 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +542 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +369 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +42 -0
- fbuild/daemon/async_client.py +531 -0
- fbuild/daemon/client.py +1505 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/configuration_lock.py +865 -0
- fbuild/daemon/daemon.py +585 -0
- fbuild/daemon/daemon_context.py +293 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/firmware_ledger.py +546 -0
- fbuild/daemon/lock_manager.py +508 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +957 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/port_state_manager.py +249 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +18 -0
- fbuild/daemon/processors/build_processor.py +248 -0
- fbuild/daemon/processors/deploy_processor.py +664 -0
- fbuild/daemon/processors/install_deps_processor.py +431 -0
- fbuild/daemon/processors/locking_processor.py +777 -0
- fbuild/daemon/processors/monitor_processor.py +285 -0
- fbuild/daemon/request_processor.py +457 -0
- fbuild/daemon/shared_serial.py +819 -0
- fbuild/daemon/status_manager.py +238 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +21 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +310 -0
- fbuild/deploy/docker_utils.py +315 -0
- fbuild/deploy/monitor.py +519 -0
- fbuild/deploy/qemu_runner.py +603 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/ledger/__init__.py +52 -0
- fbuild/ledger/board_ledger.py +560 -0
- fbuild/output.py +352 -0
- fbuild/packages/__init__.py +66 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +256 -0
- fbuild/packages/concurrent_manager.py +510 -0
- fbuild/packages/downloader.py +518 -0
- fbuild/packages/fingerprint.py +423 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_rp2040.py +349 -0
- fbuild/packages/framework_stm32.py +459 -0
- fbuild/packages/framework_teensy.py +346 -0
- fbuild/packages/github_utils.py +96 -0
- fbuild/packages/header_trampoline_cache.py +394 -0
- fbuild/packages/library_compiler.py +203 -0
- fbuild/packages/library_manager.py +549 -0
- fbuild/packages/library_manager_esp32.py +725 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_rp2040.py +400 -0
- fbuild/packages/platform_stm32.py +581 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +369 -0
- fbuild/packages/sdk_utils.py +231 -0
- fbuild/packages/toolchain.py +436 -0
- fbuild/packages/toolchain_binaries.py +196 -0
- fbuild/packages/toolchain_esp32.py +489 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_rp2040.py +436 -0
- fbuild/packages/toolchain_stm32.py +417 -0
- fbuild/packages/toolchain_teensy.py +404 -0
- fbuild/platform_configs/esp32.json +150 -0
- fbuild/platform_configs/esp32c2.json +144 -0
- fbuild/platform_configs/esp32c3.json +143 -0
- fbuild/platform_configs/esp32c5.json +151 -0
- fbuild/platform_configs/esp32c6.json +151 -0
- fbuild/platform_configs/esp32p4.json +149 -0
- fbuild/platform_configs/esp32s3.json +151 -0
- fbuild/platform_configs/imxrt1062.json +56 -0
- fbuild/platform_configs/rp2040.json +70 -0
- fbuild/platform_configs/rp2350.json +76 -0
- fbuild/platform_configs/stm32f1.json +59 -0
- fbuild/platform_configs/stm32f4.json +63 -0
- fbuild/py.typed +0 -0
- fbuild-1.2.8.dist-info/METADATA +468 -0
- fbuild-1.2.8.dist-info/RECORD +121 -0
- fbuild-1.2.8.dist-info/WHEEL +5 -0
- fbuild-1.2.8.dist-info/entry_points.txt +5 -0
- fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
- fbuild-1.2.8.dist-info/top_level.txt +2 -0
- fbuild_lint/__init__.py +0 -0
- fbuild_lint/ruff_plugins/__init__.py +0 -0
- fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
fbuild/daemon/client.py
ADDED
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fbuild Daemon Client
|
|
3
|
+
|
|
4
|
+
Client interface for requesting deploy and monitor operations from the daemon.
|
|
5
|
+
Handles daemon lifecycle, request submission, and progress monitoring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import _thread
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import psutil
|
|
19
|
+
|
|
20
|
+
from fbuild.daemon.messages import (
|
|
21
|
+
BuildRequest,
|
|
22
|
+
DaemonState,
|
|
23
|
+
DaemonStatus,
|
|
24
|
+
DeployRequest,
|
|
25
|
+
InstallDependenciesRequest,
|
|
26
|
+
MonitorRequest,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Spinner characters for progress indication
|
|
30
|
+
SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
31
|
+
|
|
32
|
+
# Daemon configuration (must match daemon settings)
|
|
33
|
+
DAEMON_NAME = "fbuild_daemon"
|
|
34
|
+
|
|
35
|
+
# Check for development mode (when running from repo)
|
|
36
|
+
if os.environ.get("FBUILD_DEV_MODE") == "1":
|
|
37
|
+
# Use project-local daemon directory for development
|
|
38
|
+
DAEMON_DIR = Path.cwd() / ".fbuild" / "daemon_dev"
|
|
39
|
+
else:
|
|
40
|
+
# Use home directory for production
|
|
41
|
+
DAEMON_DIR = Path.home() / ".fbuild" / "daemon"
|
|
42
|
+
|
|
43
|
+
PID_FILE = DAEMON_DIR / f"{DAEMON_NAME}.pid"
|
|
44
|
+
STATUS_FILE = DAEMON_DIR / "daemon_status.json"
|
|
45
|
+
BUILD_REQUEST_FILE = DAEMON_DIR / "build_request.json"
|
|
46
|
+
DEPLOY_REQUEST_FILE = DAEMON_DIR / "deploy_request.json"
|
|
47
|
+
MONITOR_REQUEST_FILE = DAEMON_DIR / "monitor_request.json"
|
|
48
|
+
INSTALL_DEPS_REQUEST_FILE = DAEMON_DIR / "install_deps_request.json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_daemon_running() -> bool:
|
|
52
|
+
"""Check if daemon is running, clean up stale PID files.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if daemon is running, False otherwise
|
|
56
|
+
"""
|
|
57
|
+
if not PID_FILE.exists():
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with open(PID_FILE) as f:
|
|
62
|
+
pid = int(f.read().strip())
|
|
63
|
+
|
|
64
|
+
# Check if process exists
|
|
65
|
+
if psutil.pid_exists(pid):
|
|
66
|
+
return True
|
|
67
|
+
else:
|
|
68
|
+
# Stale PID file - remove it
|
|
69
|
+
print(f"Removing stale PID file: {PID_FILE}")
|
|
70
|
+
PID_FILE.unlink()
|
|
71
|
+
return False
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
_thread.interrupt_main()
|
|
74
|
+
raise
|
|
75
|
+
except Exception:
|
|
76
|
+
# Corrupted PID file - remove it
|
|
77
|
+
try:
|
|
78
|
+
PID_FILE.unlink(missing_ok=True)
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
_thread.interrupt_main()
|
|
81
|
+
raise
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def start_daemon() -> None:
|
|
88
|
+
"""Start the daemon process."""
|
|
89
|
+
daemon_script = Path(__file__).parent / "daemon.py"
|
|
90
|
+
|
|
91
|
+
if not daemon_script.exists():
|
|
92
|
+
raise RuntimeError(f"Daemon script not found: {daemon_script}")
|
|
93
|
+
|
|
94
|
+
# Start daemon in background
|
|
95
|
+
subprocess.Popen(
|
|
96
|
+
[sys.executable, str(daemon_script)],
|
|
97
|
+
stdout=subprocess.DEVNULL,
|
|
98
|
+
stderr=subprocess.DEVNULL,
|
|
99
|
+
stdin=subprocess.DEVNULL,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_status_file() -> DaemonStatus:
|
|
104
|
+
"""Read current daemon status with corruption recovery.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
DaemonStatus object (or default status if file doesn't exist or corrupted)
|
|
108
|
+
"""
|
|
109
|
+
if not STATUS_FILE.exists():
|
|
110
|
+
return DaemonStatus(
|
|
111
|
+
state=DaemonState.UNKNOWN,
|
|
112
|
+
message="Status file not found",
|
|
113
|
+
updated_at=time.time(),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with open(STATUS_FILE) as f:
|
|
118
|
+
data = json.load(f)
|
|
119
|
+
|
|
120
|
+
# Parse into typed DaemonStatus
|
|
121
|
+
return DaemonStatus.from_dict(data)
|
|
122
|
+
|
|
123
|
+
except (json.JSONDecodeError, ValueError):
|
|
124
|
+
# Corrupted JSON - return default status
|
|
125
|
+
return DaemonStatus(
|
|
126
|
+
state=DaemonState.UNKNOWN,
|
|
127
|
+
message="Status file corrupted (invalid JSON)",
|
|
128
|
+
updated_at=time.time(),
|
|
129
|
+
)
|
|
130
|
+
except KeyboardInterrupt:
|
|
131
|
+
_thread.interrupt_main()
|
|
132
|
+
raise
|
|
133
|
+
except Exception:
|
|
134
|
+
return DaemonStatus(
|
|
135
|
+
state=DaemonState.UNKNOWN,
|
|
136
|
+
message="Failed to read status",
|
|
137
|
+
updated_at=time.time(),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_request_file(request_file: Path, request: Any) -> None:
|
|
142
|
+
"""Atomically write request file.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
request_file: Path to request file
|
|
146
|
+
request: Request object (DeployRequest or MonitorRequest)
|
|
147
|
+
"""
|
|
148
|
+
DAEMON_DIR.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
|
|
150
|
+
# Atomic write using temporary file
|
|
151
|
+
temp_file = request_file.with_suffix(".tmp")
|
|
152
|
+
with open(temp_file, "w") as f:
|
|
153
|
+
json.dump(request.to_dict(), f, indent=2)
|
|
154
|
+
|
|
155
|
+
# Atomic rename
|
|
156
|
+
temp_file.replace(request_file)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def display_status(status: DaemonStatus, prefix: str = " ") -> None:
|
|
160
|
+
"""Display status update to user.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
status: DaemonStatus object
|
|
164
|
+
prefix: Line prefix for indentation
|
|
165
|
+
"""
|
|
166
|
+
# Show current operation if available, otherwise use message
|
|
167
|
+
display_text = status.current_operation or status.message
|
|
168
|
+
|
|
169
|
+
if status.state == DaemonState.DEPLOYING:
|
|
170
|
+
print(f"{prefix}📦 {display_text}", flush=True)
|
|
171
|
+
elif status.state == DaemonState.MONITORING:
|
|
172
|
+
print(f"{prefix}👁️ {display_text}", flush=True)
|
|
173
|
+
elif status.state == DaemonState.BUILDING:
|
|
174
|
+
print(f"{prefix}🔨 {display_text}", flush=True)
|
|
175
|
+
elif status.state == DaemonState.COMPLETED:
|
|
176
|
+
print(f"{prefix}✅ {display_text}", flush=True)
|
|
177
|
+
elif status.state == DaemonState.FAILED:
|
|
178
|
+
print(f"{prefix}❌ {display_text}", flush=True)
|
|
179
|
+
else:
|
|
180
|
+
print(f"{prefix}ℹ️ {display_text}", flush=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def display_spinner_progress(
|
|
184
|
+
status: DaemonStatus,
|
|
185
|
+
elapsed: float,
|
|
186
|
+
spinner_idx: int,
|
|
187
|
+
prefix: str = " ",
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Display spinner with elapsed time when status hasn't changed.
|
|
190
|
+
|
|
191
|
+
Uses carriage return to update in place without new line.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
status: DaemonStatus object
|
|
195
|
+
elapsed: Elapsed time in seconds
|
|
196
|
+
spinner_idx: Current spinner index
|
|
197
|
+
prefix: Line prefix for indentation
|
|
198
|
+
"""
|
|
199
|
+
spinner = SPINNER_CHARS[spinner_idx % len(SPINNER_CHARS)]
|
|
200
|
+
display_text = status.current_operation or status.message
|
|
201
|
+
|
|
202
|
+
# Format elapsed time
|
|
203
|
+
mins = int(elapsed) // 60
|
|
204
|
+
secs = int(elapsed) % 60
|
|
205
|
+
if mins > 0:
|
|
206
|
+
time_str = f"{mins}m {secs}s"
|
|
207
|
+
else:
|
|
208
|
+
time_str = f"{secs}s"
|
|
209
|
+
|
|
210
|
+
# Use carriage return to update in place
|
|
211
|
+
print(f"\r{prefix}{spinner} {display_text} ({time_str})", end="", flush=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def ensure_daemon_running() -> bool:
|
|
215
|
+
"""Ensure daemon is running, start if needed.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
True if daemon is running or started successfully, False otherwise
|
|
219
|
+
"""
|
|
220
|
+
if is_daemon_running():
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
# If we reach here, daemon is not running (stale PID was cleaned by is_daemon_running)
|
|
224
|
+
# Clear stale status file to prevent race condition where client reads old status
|
|
225
|
+
# from previous daemon run before new daemon writes fresh status
|
|
226
|
+
if STATUS_FILE.exists():
|
|
227
|
+
try:
|
|
228
|
+
STATUS_FILE.unlink()
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
_thread.interrupt_main()
|
|
231
|
+
raise
|
|
232
|
+
except Exception:
|
|
233
|
+
pass # Best effort - continue even if delete fails
|
|
234
|
+
|
|
235
|
+
print("🔗 Starting fbuild daemon...")
|
|
236
|
+
start_daemon()
|
|
237
|
+
|
|
238
|
+
# Wait up to 10 seconds for daemon to start and write fresh status
|
|
239
|
+
for _ in range(10):
|
|
240
|
+
if is_daemon_running():
|
|
241
|
+
# Daemon is running - check if status file is fresh
|
|
242
|
+
status = read_status_file()
|
|
243
|
+
if status.state != DaemonState.UNKNOWN:
|
|
244
|
+
# Valid status received from new daemon
|
|
245
|
+
print("✅ Daemon started successfully")
|
|
246
|
+
return True
|
|
247
|
+
time.sleep(1)
|
|
248
|
+
|
|
249
|
+
print("❌ Failed to start daemon")
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def request_build(
|
|
254
|
+
project_dir: Path,
|
|
255
|
+
environment: str,
|
|
256
|
+
clean_build: bool = False,
|
|
257
|
+
verbose: bool = False,
|
|
258
|
+
timeout: float = 1800,
|
|
259
|
+
) -> bool:
|
|
260
|
+
"""Request a build operation from the daemon.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
project_dir: Project directory
|
|
264
|
+
environment: Build environment
|
|
265
|
+
clean_build: Whether to perform clean build
|
|
266
|
+
verbose: Enable verbose build output
|
|
267
|
+
timeout: Maximum wait time in seconds (default: 30 minutes)
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
True if build successful, False otherwise
|
|
271
|
+
"""
|
|
272
|
+
handler = BuildRequestHandler(
|
|
273
|
+
project_dir=project_dir,
|
|
274
|
+
environment=environment,
|
|
275
|
+
clean_build=clean_build,
|
|
276
|
+
verbose=verbose,
|
|
277
|
+
timeout=timeout,
|
|
278
|
+
)
|
|
279
|
+
return handler.execute()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _display_monitor_summary(project_dir: Path) -> None:
|
|
283
|
+
"""Display monitor summary from JSON file.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
project_dir: Project directory where summary file is located
|
|
287
|
+
"""
|
|
288
|
+
summary_file = project_dir / ".fbuild" / "monitor_summary.json"
|
|
289
|
+
if not summary_file.exists():
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
with open(summary_file, "r", encoding="utf-8") as f:
|
|
294
|
+
summary = json.load(f)
|
|
295
|
+
|
|
296
|
+
print("\n" + "=" * 50)
|
|
297
|
+
print("Monitor Summary")
|
|
298
|
+
print("=" * 50)
|
|
299
|
+
|
|
300
|
+
# Display expect pattern result
|
|
301
|
+
if summary.get("expect_pattern"):
|
|
302
|
+
pattern = summary["expect_pattern"]
|
|
303
|
+
found = summary.get("expect_found", False)
|
|
304
|
+
status = "FOUND ✓" if found else "NOT FOUND ✗"
|
|
305
|
+
print(f'Expected pattern: "{pattern}" - {status}')
|
|
306
|
+
|
|
307
|
+
# Display halt on error pattern result
|
|
308
|
+
if summary.get("halt_on_error_pattern"):
|
|
309
|
+
pattern = summary["halt_on_error_pattern"]
|
|
310
|
+
found = summary.get("halt_on_error_found", False)
|
|
311
|
+
status = "FOUND ✗" if found else "NOT FOUND ✓"
|
|
312
|
+
print(f'Error pattern: "{pattern}" - {status}')
|
|
313
|
+
|
|
314
|
+
# Display halt on success pattern result
|
|
315
|
+
if summary.get("halt_on_success_pattern"):
|
|
316
|
+
pattern = summary["halt_on_success_pattern"]
|
|
317
|
+
found = summary.get("halt_on_success_found", False)
|
|
318
|
+
status = "FOUND ✓" if found else "NOT FOUND ✗"
|
|
319
|
+
print(f'Success pattern: "{pattern}" - {status}')
|
|
320
|
+
|
|
321
|
+
# Display statistics
|
|
322
|
+
lines = summary.get("lines_processed", 0)
|
|
323
|
+
elapsed = summary.get("elapsed_time", 0.0)
|
|
324
|
+
exit_reason = summary.get("exit_reason", "unknown")
|
|
325
|
+
|
|
326
|
+
print(f"Lines processed: {lines}")
|
|
327
|
+
print(f"Time elapsed: {elapsed:.2f}s")
|
|
328
|
+
|
|
329
|
+
# Translate exit_reason to user-friendly text
|
|
330
|
+
reason_text = {
|
|
331
|
+
"timeout": "Timeout reached",
|
|
332
|
+
"expect_found": "Expected pattern found",
|
|
333
|
+
"halt_error": "Error pattern detected",
|
|
334
|
+
"halt_success": "Success pattern detected",
|
|
335
|
+
"interrupted": "Interrupted by user",
|
|
336
|
+
"error": "Serial port error",
|
|
337
|
+
}.get(exit_reason, exit_reason)
|
|
338
|
+
|
|
339
|
+
print(f"Exit reason: {reason_text}")
|
|
340
|
+
print("=" * 50)
|
|
341
|
+
|
|
342
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
343
|
+
raise
|
|
344
|
+
except Exception:
|
|
345
|
+
# Silently fail - don't disrupt the user experience
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ============================================================================
|
|
350
|
+
# REQUEST HANDLER ARCHITECTURE
|
|
351
|
+
# ============================================================================
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class BaseRequestHandler(ABC):
|
|
355
|
+
"""Base class for handling daemon requests with common functionality.
|
|
356
|
+
|
|
357
|
+
Implements the template method pattern to eliminate duplication across
|
|
358
|
+
build, deploy, and monitor request handlers.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def __init__(self, project_dir: Path, environment: str, timeout: float = 1800):
|
|
362
|
+
"""Initialize request handler.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
project_dir: Project directory
|
|
366
|
+
environment: Build environment
|
|
367
|
+
timeout: Maximum wait time in seconds (default: 30 minutes)
|
|
368
|
+
"""
|
|
369
|
+
self.project_dir = project_dir
|
|
370
|
+
self.environment = environment
|
|
371
|
+
self.timeout = timeout
|
|
372
|
+
self.start_time = 0.0
|
|
373
|
+
self.last_message: str | None = None
|
|
374
|
+
self.monitoring_started = False
|
|
375
|
+
self.output_file_position = 0
|
|
376
|
+
self.spinner_idx = 0
|
|
377
|
+
self.last_spinner_update = 0.0
|
|
378
|
+
|
|
379
|
+
@abstractmethod
|
|
380
|
+
def create_request(self) -> BuildRequest | DeployRequest | InstallDependenciesRequest | MonitorRequest:
|
|
381
|
+
"""Create the specific request object.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Request object (BuildRequest, DeployRequest, InstallDependenciesRequest, or MonitorRequest)
|
|
385
|
+
"""
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
@abstractmethod
|
|
389
|
+
def get_request_file(self) -> Path:
|
|
390
|
+
"""Get the request file path.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Path to request file
|
|
394
|
+
"""
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
@abstractmethod
|
|
398
|
+
def get_operation_name(self) -> str:
|
|
399
|
+
"""Get the operation name for display.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Operation name (e.g., "Build", "Deploy", "Monitor")
|
|
403
|
+
"""
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
@abstractmethod
|
|
407
|
+
def get_operation_emoji(self) -> str:
|
|
408
|
+
"""Get the operation emoji for display.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Operation emoji (e.g., "🔨", "📦", "👁️")
|
|
412
|
+
"""
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
def should_tail_output(self) -> bool:
|
|
416
|
+
"""Check if output file should be tailed.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
True if output should be tailed, False otherwise
|
|
420
|
+
"""
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
def on_monitoring_started(self) -> None:
|
|
424
|
+
"""Hook called when monitoring phase starts."""
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
def on_completion(self, elapsed: float) -> None:
|
|
428
|
+
"""Hook called on successful completion.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
elapsed: Elapsed time in seconds
|
|
432
|
+
"""
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
def on_failure(self, status: DaemonStatus, elapsed: float) -> None:
|
|
436
|
+
"""Hook called on failure.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
status: Current daemon status
|
|
440
|
+
elapsed: Elapsed time in seconds
|
|
441
|
+
"""
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
def print_submission_info(self) -> None:
|
|
445
|
+
"""Print request submission information."""
|
|
446
|
+
print(f"\n📤 Submitting {self.get_operation_name().lower()} request...")
|
|
447
|
+
print(f" Project: {self.project_dir}")
|
|
448
|
+
print(f" Environment: {self.environment}")
|
|
449
|
+
|
|
450
|
+
def tail_output_file(self) -> None:
|
|
451
|
+
"""Tail the output file and print new lines."""
|
|
452
|
+
output_file = self.project_dir / ".fbuild" / "monitor_output.txt"
|
|
453
|
+
if output_file.exists():
|
|
454
|
+
try:
|
|
455
|
+
with open(output_file, "r", encoding="utf-8", errors="replace") as f:
|
|
456
|
+
f.seek(self.output_file_position)
|
|
457
|
+
new_lines = f.read()
|
|
458
|
+
if new_lines:
|
|
459
|
+
print(new_lines, end="", flush=True)
|
|
460
|
+
self.output_file_position = f.tell()
|
|
461
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
462
|
+
raise
|
|
463
|
+
except Exception:
|
|
464
|
+
pass # Ignore read errors
|
|
465
|
+
|
|
466
|
+
def read_remaining_output(self) -> None:
|
|
467
|
+
"""Read any remaining output from output file."""
|
|
468
|
+
if not self.monitoring_started:
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
output_file = self.project_dir / ".fbuild" / "monitor_output.txt"
|
|
472
|
+
if output_file.exists():
|
|
473
|
+
try:
|
|
474
|
+
with open(output_file, "r", encoding="utf-8", errors="replace") as f:
|
|
475
|
+
f.seek(self.output_file_position)
|
|
476
|
+
new_lines = f.read()
|
|
477
|
+
if new_lines:
|
|
478
|
+
print(new_lines, end="", flush=True)
|
|
479
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
480
|
+
raise
|
|
481
|
+
except Exception:
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
def handle_keyboard_interrupt(self, request_id: str) -> bool:
|
|
485
|
+
"""Handle keyboard interrupt with background option.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
request_id: Request ID for cancellation
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
False (operation not completed or cancelled)
|
|
492
|
+
"""
|
|
493
|
+
print("\n\n⚠️ Interrupted by user (Ctrl-C)")
|
|
494
|
+
response = input("Keep operation running in background? (y/n): ").strip().lower()
|
|
495
|
+
|
|
496
|
+
if response in ("y", "yes"):
|
|
497
|
+
print("\n✅ Operation continues in background")
|
|
498
|
+
print(" Check status: fbuild daemon status")
|
|
499
|
+
print(" Stop daemon: fbuild daemon stop")
|
|
500
|
+
return False
|
|
501
|
+
else:
|
|
502
|
+
print("\n🛑 Requesting daemon to stop operation...")
|
|
503
|
+
cancel_file = DAEMON_DIR / f"cancel_{request_id}.signal"
|
|
504
|
+
cancel_file.touch()
|
|
505
|
+
print(" Operation cancellation requested")
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
def execute(self) -> bool:
|
|
509
|
+
"""Execute the request and monitor progress.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
True if operation successful, False otherwise
|
|
513
|
+
"""
|
|
514
|
+
# Ensure daemon is running
|
|
515
|
+
if not ensure_daemon_running():
|
|
516
|
+
return False
|
|
517
|
+
|
|
518
|
+
# Print submission info
|
|
519
|
+
self.print_submission_info()
|
|
520
|
+
|
|
521
|
+
# Create and submit request
|
|
522
|
+
request = self.create_request()
|
|
523
|
+
write_request_file(self.get_request_file(), request)
|
|
524
|
+
print(f" Request ID: {request.request_id}")
|
|
525
|
+
print(" ✅ Submitted\n")
|
|
526
|
+
|
|
527
|
+
# Monitor progress
|
|
528
|
+
print(f"{self.get_operation_emoji()} {self.get_operation_name()} Progress:")
|
|
529
|
+
self.start_time = time.time()
|
|
530
|
+
|
|
531
|
+
while True:
|
|
532
|
+
try:
|
|
533
|
+
elapsed = time.time() - self.start_time
|
|
534
|
+
|
|
535
|
+
# Check timeout
|
|
536
|
+
if elapsed > self.timeout:
|
|
537
|
+
print(f"\n❌ {self.get_operation_name()} timeout ({self.timeout}s)")
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
# Read status
|
|
541
|
+
status = read_status_file()
|
|
542
|
+
|
|
543
|
+
# Display progress when message changes
|
|
544
|
+
if status.message != self.last_message:
|
|
545
|
+
# Clear spinner line before new status message
|
|
546
|
+
if self.last_message is not None:
|
|
547
|
+
print("\r" + " " * 80 + "\r", end="", flush=True)
|
|
548
|
+
display_status(status)
|
|
549
|
+
self.last_message = status.message
|
|
550
|
+
self.last_spinner_update = time.time()
|
|
551
|
+
else:
|
|
552
|
+
# Show spinner with elapsed time when in building/deploying state
|
|
553
|
+
if status.state in (DaemonState.BUILDING, DaemonState.DEPLOYING):
|
|
554
|
+
current_time = time.time()
|
|
555
|
+
# Update spinner every 100ms
|
|
556
|
+
if current_time - self.last_spinner_update >= 0.1:
|
|
557
|
+
self.spinner_idx += 1
|
|
558
|
+
display_spinner_progress(status, elapsed, self.spinner_idx)
|
|
559
|
+
self.last_spinner_update = current_time
|
|
560
|
+
|
|
561
|
+
# Handle monitoring phase
|
|
562
|
+
if self.should_tail_output() and status.state == DaemonState.MONITORING:
|
|
563
|
+
if not self.monitoring_started:
|
|
564
|
+
self.monitoring_started = True
|
|
565
|
+
# Clear spinner line before monitor output
|
|
566
|
+
print("\r" + " " * 80 + "\r", end="", flush=True)
|
|
567
|
+
print() # Blank line before serial output
|
|
568
|
+
self.on_monitoring_started()
|
|
569
|
+
|
|
570
|
+
if self.monitoring_started and self.should_tail_output():
|
|
571
|
+
self.tail_output_file()
|
|
572
|
+
|
|
573
|
+
# Check completion
|
|
574
|
+
if status.state == DaemonState.COMPLETED:
|
|
575
|
+
if status.request_id == request.request_id:
|
|
576
|
+
self.read_remaining_output()
|
|
577
|
+
self.on_completion(elapsed)
|
|
578
|
+
# Clear spinner line before completion message
|
|
579
|
+
print("\r" + " " * 80 + "\r", end="", flush=True)
|
|
580
|
+
print(f"✅ {self.get_operation_name()} completed in {elapsed:.1f}s")
|
|
581
|
+
return True
|
|
582
|
+
|
|
583
|
+
elif status.state == DaemonState.FAILED:
|
|
584
|
+
if status.request_id == request.request_id:
|
|
585
|
+
self.read_remaining_output()
|
|
586
|
+
self.on_failure(status, elapsed)
|
|
587
|
+
# Clear spinner line before failure message
|
|
588
|
+
print("\r" + " " * 80 + "\r", end="", flush=True)
|
|
589
|
+
print(f"❌ {self.get_operation_name()} failed: {status.message}")
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
# Sleep before next poll
|
|
593
|
+
poll_interval = 0.1 if self.monitoring_started else 0.1 # Faster polling for spinner
|
|
594
|
+
time.sleep(poll_interval)
|
|
595
|
+
|
|
596
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
597
|
+
# Clear spinner line before interrupt handling
|
|
598
|
+
print("\r" + " " * 80 + "\r", end="", flush=True)
|
|
599
|
+
return self.handle_keyboard_interrupt(request.request_id)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class BuildRequestHandler(BaseRequestHandler):
|
|
603
|
+
"""Handler for build requests."""
|
|
604
|
+
|
|
605
|
+
def __init__(
|
|
606
|
+
self,
|
|
607
|
+
project_dir: Path,
|
|
608
|
+
environment: str,
|
|
609
|
+
clean_build: bool = False,
|
|
610
|
+
verbose: bool = False,
|
|
611
|
+
timeout: float = 1800,
|
|
612
|
+
):
|
|
613
|
+
"""Initialize build request handler.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
project_dir: Project directory
|
|
617
|
+
environment: Build environment
|
|
618
|
+
clean_build: Whether to perform clean build
|
|
619
|
+
verbose: Enable verbose build output
|
|
620
|
+
timeout: Maximum wait time in seconds
|
|
621
|
+
"""
|
|
622
|
+
super().__init__(project_dir, environment, timeout)
|
|
623
|
+
self.clean_build = clean_build
|
|
624
|
+
self.verbose = verbose
|
|
625
|
+
|
|
626
|
+
def create_request(self) -> BuildRequest:
|
|
627
|
+
"""Create build request."""
|
|
628
|
+
return BuildRequest(
|
|
629
|
+
project_dir=str(self.project_dir.absolute()),
|
|
630
|
+
environment=self.environment,
|
|
631
|
+
clean_build=self.clean_build,
|
|
632
|
+
verbose=self.verbose,
|
|
633
|
+
caller_pid=os.getpid(),
|
|
634
|
+
caller_cwd=os.getcwd(),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def get_request_file(self) -> Path:
|
|
638
|
+
"""Get build request file path."""
|
|
639
|
+
return BUILD_REQUEST_FILE
|
|
640
|
+
|
|
641
|
+
def get_operation_name(self) -> str:
|
|
642
|
+
"""Get operation name."""
|
|
643
|
+
return "Build"
|
|
644
|
+
|
|
645
|
+
def get_operation_emoji(self) -> str:
|
|
646
|
+
"""Get operation emoji."""
|
|
647
|
+
return "🔨"
|
|
648
|
+
|
|
649
|
+
def print_submission_info(self) -> None:
|
|
650
|
+
"""Print build submission information."""
|
|
651
|
+
super().print_submission_info()
|
|
652
|
+
if self.clean_build:
|
|
653
|
+
print(" Clean build: Yes")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class DeployRequestHandler(BaseRequestHandler):
|
|
657
|
+
"""Handler for deploy requests."""
|
|
658
|
+
|
|
659
|
+
def __init__(
|
|
660
|
+
self,
|
|
661
|
+
project_dir: Path,
|
|
662
|
+
environment: str,
|
|
663
|
+
port: str | None = None,
|
|
664
|
+
clean_build: bool = False,
|
|
665
|
+
monitor_after: bool = False,
|
|
666
|
+
monitor_timeout: float | None = None,
|
|
667
|
+
monitor_halt_on_error: str | None = None,
|
|
668
|
+
monitor_halt_on_success: str | None = None,
|
|
669
|
+
monitor_expect: str | None = None,
|
|
670
|
+
monitor_show_timestamp: bool = False,
|
|
671
|
+
timeout: float = 1800,
|
|
672
|
+
):
|
|
673
|
+
"""Initialize deploy request handler.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
project_dir: Project directory
|
|
677
|
+
environment: Build environment
|
|
678
|
+
port: Serial port (optional)
|
|
679
|
+
clean_build: Whether to perform clean build
|
|
680
|
+
monitor_after: Whether to start monitor after deploy
|
|
681
|
+
monitor_timeout: Timeout for monitor
|
|
682
|
+
monitor_halt_on_error: Pattern to halt on error
|
|
683
|
+
monitor_halt_on_success: Pattern to halt on success
|
|
684
|
+
monitor_expect: Expected pattern to check
|
|
685
|
+
monitor_show_timestamp: Whether to prefix output lines with elapsed time
|
|
686
|
+
timeout: Maximum wait time in seconds
|
|
687
|
+
"""
|
|
688
|
+
super().__init__(project_dir, environment, timeout)
|
|
689
|
+
self.port = port
|
|
690
|
+
self.clean_build = clean_build
|
|
691
|
+
self.monitor_after = monitor_after
|
|
692
|
+
self.monitor_timeout = monitor_timeout
|
|
693
|
+
self.monitor_halt_on_error = monitor_halt_on_error
|
|
694
|
+
self.monitor_halt_on_success = monitor_halt_on_success
|
|
695
|
+
self.monitor_expect = monitor_expect
|
|
696
|
+
self.monitor_show_timestamp = monitor_show_timestamp
|
|
697
|
+
|
|
698
|
+
def create_request(self) -> DeployRequest:
|
|
699
|
+
"""Create deploy request."""
|
|
700
|
+
return DeployRequest(
|
|
701
|
+
project_dir=str(self.project_dir.absolute()),
|
|
702
|
+
environment=self.environment,
|
|
703
|
+
port=self.port,
|
|
704
|
+
clean_build=self.clean_build,
|
|
705
|
+
monitor_after=self.monitor_after,
|
|
706
|
+
monitor_timeout=self.monitor_timeout,
|
|
707
|
+
monitor_halt_on_error=self.monitor_halt_on_error,
|
|
708
|
+
monitor_halt_on_success=self.monitor_halt_on_success,
|
|
709
|
+
monitor_expect=self.monitor_expect,
|
|
710
|
+
monitor_show_timestamp=self.monitor_show_timestamp,
|
|
711
|
+
caller_pid=os.getpid(),
|
|
712
|
+
caller_cwd=os.getcwd(),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
def get_request_file(self) -> Path:
|
|
716
|
+
"""Get deploy request file path."""
|
|
717
|
+
return DEPLOY_REQUEST_FILE
|
|
718
|
+
|
|
719
|
+
def get_operation_name(self) -> str:
|
|
720
|
+
"""Get operation name."""
|
|
721
|
+
return "Deploy"
|
|
722
|
+
|
|
723
|
+
def get_operation_emoji(self) -> str:
|
|
724
|
+
"""Get operation emoji."""
|
|
725
|
+
return "📦"
|
|
726
|
+
|
|
727
|
+
def should_tail_output(self) -> bool:
|
|
728
|
+
"""Check if output should be tailed."""
|
|
729
|
+
return self.monitor_after
|
|
730
|
+
|
|
731
|
+
def print_submission_info(self) -> None:
|
|
732
|
+
"""Print deploy submission information."""
|
|
733
|
+
super().print_submission_info()
|
|
734
|
+
if self.port:
|
|
735
|
+
print(f" Port: {self.port}")
|
|
736
|
+
|
|
737
|
+
def on_completion(self, elapsed: float) -> None:
|
|
738
|
+
"""Handle completion with monitor summary."""
|
|
739
|
+
if self.monitoring_started:
|
|
740
|
+
_display_monitor_summary(self.project_dir)
|
|
741
|
+
|
|
742
|
+
def on_failure(self, status: DaemonStatus, elapsed: float) -> None:
|
|
743
|
+
"""Handle failure with monitor summary."""
|
|
744
|
+
if self.monitoring_started:
|
|
745
|
+
_display_monitor_summary(self.project_dir)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
class MonitorRequestHandler(BaseRequestHandler):
|
|
749
|
+
"""Handler for monitor requests."""
|
|
750
|
+
|
|
751
|
+
def __init__(
|
|
752
|
+
self,
|
|
753
|
+
project_dir: Path,
|
|
754
|
+
environment: str,
|
|
755
|
+
port: str | None = None,
|
|
756
|
+
baud_rate: int | None = None,
|
|
757
|
+
halt_on_error: str | None = None,
|
|
758
|
+
halt_on_success: str | None = None,
|
|
759
|
+
expect: str | None = None,
|
|
760
|
+
timeout: float | None = None,
|
|
761
|
+
show_timestamp: bool = False,
|
|
762
|
+
):
|
|
763
|
+
"""Initialize monitor request handler.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
project_dir: Project directory
|
|
767
|
+
environment: Build environment
|
|
768
|
+
port: Serial port (optional)
|
|
769
|
+
baud_rate: Serial baud rate (optional)
|
|
770
|
+
halt_on_error: Pattern to halt on error
|
|
771
|
+
halt_on_success: Pattern to halt on success
|
|
772
|
+
expect: Expected pattern to check
|
|
773
|
+
timeout: Maximum monitoring time in seconds
|
|
774
|
+
show_timestamp: Whether to prefix output lines with elapsed time
|
|
775
|
+
"""
|
|
776
|
+
super().__init__(project_dir, environment, timeout or 3600)
|
|
777
|
+
self.port = port
|
|
778
|
+
self.baud_rate = baud_rate
|
|
779
|
+
self.halt_on_error = halt_on_error
|
|
780
|
+
self.halt_on_success = halt_on_success
|
|
781
|
+
self.expect = expect
|
|
782
|
+
self.monitor_timeout = timeout
|
|
783
|
+
self.show_timestamp = show_timestamp
|
|
784
|
+
|
|
785
|
+
def create_request(self) -> MonitorRequest:
|
|
786
|
+
"""Create monitor request."""
|
|
787
|
+
return MonitorRequest(
|
|
788
|
+
project_dir=str(self.project_dir.absolute()),
|
|
789
|
+
environment=self.environment,
|
|
790
|
+
port=self.port,
|
|
791
|
+
baud_rate=self.baud_rate,
|
|
792
|
+
halt_on_error=self.halt_on_error,
|
|
793
|
+
halt_on_success=self.halt_on_success,
|
|
794
|
+
expect=self.expect,
|
|
795
|
+
timeout=self.monitor_timeout,
|
|
796
|
+
caller_pid=os.getpid(),
|
|
797
|
+
caller_cwd=os.getcwd(),
|
|
798
|
+
show_timestamp=self.show_timestamp,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
def get_request_file(self) -> Path:
|
|
802
|
+
"""Get monitor request file path."""
|
|
803
|
+
return MONITOR_REQUEST_FILE
|
|
804
|
+
|
|
805
|
+
def get_operation_name(self) -> str:
|
|
806
|
+
"""Get operation name."""
|
|
807
|
+
return "Monitor"
|
|
808
|
+
|
|
809
|
+
def get_operation_emoji(self) -> str:
|
|
810
|
+
"""Get operation emoji."""
|
|
811
|
+
return "👁️"
|
|
812
|
+
|
|
813
|
+
def should_tail_output(self) -> bool:
|
|
814
|
+
"""Check if output should be tailed."""
|
|
815
|
+
return True
|
|
816
|
+
|
|
817
|
+
def print_submission_info(self) -> None:
|
|
818
|
+
"""Print monitor submission information."""
|
|
819
|
+
super().print_submission_info()
|
|
820
|
+
if self.port:
|
|
821
|
+
print(f" Port: {self.port}")
|
|
822
|
+
if self.baud_rate:
|
|
823
|
+
print(f" Baud rate: {self.baud_rate}")
|
|
824
|
+
if self.monitor_timeout:
|
|
825
|
+
print(f" Timeout: {self.monitor_timeout}s")
|
|
826
|
+
|
|
827
|
+
def on_completion(self, elapsed: float) -> None:
|
|
828
|
+
"""Handle completion with monitor summary."""
|
|
829
|
+
if self.monitoring_started:
|
|
830
|
+
_display_monitor_summary(self.project_dir)
|
|
831
|
+
|
|
832
|
+
def on_failure(self, status: DaemonStatus, elapsed: float) -> None:
|
|
833
|
+
"""Handle failure with monitor summary."""
|
|
834
|
+
if self.monitoring_started:
|
|
835
|
+
_display_monitor_summary(self.project_dir)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
class InstallDependenciesRequestHandler(BaseRequestHandler):
|
|
839
|
+
"""Handler for install dependencies requests."""
|
|
840
|
+
|
|
841
|
+
def __init__(
|
|
842
|
+
self,
|
|
843
|
+
project_dir: Path,
|
|
844
|
+
environment: str,
|
|
845
|
+
verbose: bool = False,
|
|
846
|
+
timeout: float = 1800,
|
|
847
|
+
):
|
|
848
|
+
"""Initialize install dependencies request handler.
|
|
849
|
+
|
|
850
|
+
Args:
|
|
851
|
+
project_dir: Project directory
|
|
852
|
+
environment: Build environment
|
|
853
|
+
verbose: Enable verbose output
|
|
854
|
+
timeout: Maximum wait time in seconds
|
|
855
|
+
"""
|
|
856
|
+
super().__init__(project_dir, environment, timeout)
|
|
857
|
+
self.verbose = verbose
|
|
858
|
+
|
|
859
|
+
def create_request(self) -> InstallDependenciesRequest:
|
|
860
|
+
"""Create install dependencies request."""
|
|
861
|
+
return InstallDependenciesRequest(
|
|
862
|
+
project_dir=str(self.project_dir.absolute()),
|
|
863
|
+
environment=self.environment,
|
|
864
|
+
verbose=self.verbose,
|
|
865
|
+
caller_pid=os.getpid(),
|
|
866
|
+
caller_cwd=os.getcwd(),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
def get_request_file(self) -> Path:
|
|
870
|
+
"""Get install dependencies request file path."""
|
|
871
|
+
return INSTALL_DEPS_REQUEST_FILE
|
|
872
|
+
|
|
873
|
+
def get_operation_name(self) -> str:
|
|
874
|
+
"""Get operation name."""
|
|
875
|
+
return "Install Dependencies"
|
|
876
|
+
|
|
877
|
+
def get_operation_emoji(self) -> str:
|
|
878
|
+
"""Get operation emoji."""
|
|
879
|
+
return "📦"
|
|
880
|
+
|
|
881
|
+
def print_submission_info(self) -> None:
|
|
882
|
+
"""Print install dependencies submission information."""
|
|
883
|
+
super().print_submission_info()
|
|
884
|
+
if self.verbose:
|
|
885
|
+
print(" Verbose: Yes")
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def request_install_dependencies(
|
|
889
|
+
project_dir: Path,
|
|
890
|
+
environment: str,
|
|
891
|
+
verbose: bool = False,
|
|
892
|
+
timeout: float = 1800,
|
|
893
|
+
) -> bool:
|
|
894
|
+
"""Request a dependency installation operation from the daemon.
|
|
895
|
+
|
|
896
|
+
This pre-installs toolchain, platform, framework, and libraries without
|
|
897
|
+
actually performing a build. Useful for:
|
|
898
|
+
- Pre-warming the cache before builds
|
|
899
|
+
- Ensuring dependencies are available offline
|
|
900
|
+
- Separating dependency installation from compilation
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
project_dir: Project directory
|
|
904
|
+
environment: Build environment
|
|
905
|
+
verbose: Enable verbose output
|
|
906
|
+
timeout: Maximum wait time in seconds (default: 30 minutes)
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
True if dependencies installed successfully, False otherwise
|
|
910
|
+
"""
|
|
911
|
+
handler = InstallDependenciesRequestHandler(
|
|
912
|
+
project_dir=project_dir,
|
|
913
|
+
environment=environment,
|
|
914
|
+
verbose=verbose,
|
|
915
|
+
timeout=timeout,
|
|
916
|
+
)
|
|
917
|
+
return handler.execute()
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def request_deploy(
|
|
921
|
+
project_dir: Path,
|
|
922
|
+
environment: str,
|
|
923
|
+
port: str | None = None,
|
|
924
|
+
clean_build: bool = False,
|
|
925
|
+
monitor_after: bool = False,
|
|
926
|
+
monitor_timeout: float | None = None,
|
|
927
|
+
monitor_halt_on_error: str | None = None,
|
|
928
|
+
monitor_halt_on_success: str | None = None,
|
|
929
|
+
monitor_expect: str | None = None,
|
|
930
|
+
monitor_show_timestamp: bool = False,
|
|
931
|
+
timeout: float = 1800,
|
|
932
|
+
) -> bool:
|
|
933
|
+
"""Request a deploy operation from the daemon.
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
project_dir: Project directory
|
|
937
|
+
environment: Build environment
|
|
938
|
+
port: Serial port (optional, auto-detect if None)
|
|
939
|
+
clean_build: Whether to perform clean build
|
|
940
|
+
monitor_after: Whether to start monitor after deploy
|
|
941
|
+
monitor_timeout: Timeout for monitor (if monitor_after=True)
|
|
942
|
+
monitor_halt_on_error: Pattern to halt on error (if monitor_after=True)
|
|
943
|
+
monitor_halt_on_success: Pattern to halt on success (if monitor_after=True)
|
|
944
|
+
monitor_expect: Expected pattern to check at timeout/success (if monitor_after=True)
|
|
945
|
+
monitor_show_timestamp: Whether to prefix output lines with elapsed time (SS.HH format)
|
|
946
|
+
timeout: Maximum wait time in seconds (default: 30 minutes)
|
|
947
|
+
|
|
948
|
+
Returns:
|
|
949
|
+
True if deploy successful, False otherwise
|
|
950
|
+
"""
|
|
951
|
+
handler = DeployRequestHandler(
|
|
952
|
+
project_dir=project_dir,
|
|
953
|
+
environment=environment,
|
|
954
|
+
port=port,
|
|
955
|
+
clean_build=clean_build,
|
|
956
|
+
monitor_after=monitor_after,
|
|
957
|
+
monitor_timeout=monitor_timeout,
|
|
958
|
+
monitor_halt_on_error=monitor_halt_on_error,
|
|
959
|
+
monitor_halt_on_success=monitor_halt_on_success,
|
|
960
|
+
monitor_expect=monitor_expect,
|
|
961
|
+
monitor_show_timestamp=monitor_show_timestamp,
|
|
962
|
+
timeout=timeout,
|
|
963
|
+
)
|
|
964
|
+
return handler.execute()
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def request_monitor(
|
|
968
|
+
project_dir: Path,
|
|
969
|
+
environment: str,
|
|
970
|
+
port: str | None = None,
|
|
971
|
+
baud_rate: int | None = None,
|
|
972
|
+
halt_on_error: str | None = None,
|
|
973
|
+
halt_on_success: str | None = None,
|
|
974
|
+
expect: str | None = None,
|
|
975
|
+
timeout: float | None = None,
|
|
976
|
+
show_timestamp: bool = False,
|
|
977
|
+
) -> bool:
|
|
978
|
+
"""Request a monitor operation from the daemon.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
project_dir: Project directory
|
|
982
|
+
environment: Build environment
|
|
983
|
+
port: Serial port (optional, auto-detect if None)
|
|
984
|
+
baud_rate: Serial baud rate (optional)
|
|
985
|
+
halt_on_error: Pattern to halt on (error detection)
|
|
986
|
+
halt_on_success: Pattern to halt on (success detection)
|
|
987
|
+
expect: Expected pattern to check at timeout/success
|
|
988
|
+
timeout: Maximum monitoring time in seconds
|
|
989
|
+
show_timestamp: Whether to prefix output lines with elapsed time (SS.HH format)
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
True if monitoring successful, False otherwise
|
|
993
|
+
"""
|
|
994
|
+
handler = MonitorRequestHandler(
|
|
995
|
+
project_dir=project_dir,
|
|
996
|
+
environment=environment,
|
|
997
|
+
port=port,
|
|
998
|
+
baud_rate=baud_rate,
|
|
999
|
+
halt_on_error=halt_on_error,
|
|
1000
|
+
halt_on_success=halt_on_success,
|
|
1001
|
+
expect=expect,
|
|
1002
|
+
timeout=timeout,
|
|
1003
|
+
show_timestamp=show_timestamp,
|
|
1004
|
+
)
|
|
1005
|
+
return handler.execute()
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def stop_daemon() -> bool:
|
|
1009
|
+
"""Stop the daemon gracefully.
|
|
1010
|
+
|
|
1011
|
+
Returns:
|
|
1012
|
+
True if daemon was stopped, False otherwise
|
|
1013
|
+
"""
|
|
1014
|
+
if not is_daemon_running():
|
|
1015
|
+
print("Daemon is not running")
|
|
1016
|
+
return False
|
|
1017
|
+
|
|
1018
|
+
# Create shutdown signal file
|
|
1019
|
+
shutdown_file = DAEMON_DIR / "shutdown.signal"
|
|
1020
|
+
shutdown_file.touch()
|
|
1021
|
+
|
|
1022
|
+
# Wait for daemon to exit
|
|
1023
|
+
print("Stopping daemon...")
|
|
1024
|
+
for _ in range(10):
|
|
1025
|
+
if not is_daemon_running():
|
|
1026
|
+
print("✅ Daemon stopped")
|
|
1027
|
+
return True
|
|
1028
|
+
time.sleep(1)
|
|
1029
|
+
|
|
1030
|
+
print("⚠️ Daemon did not stop gracefully")
|
|
1031
|
+
return False
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def get_daemon_status() -> dict[str, Any]:
|
|
1035
|
+
"""Get current daemon status.
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
Dictionary with daemon status information
|
|
1039
|
+
"""
|
|
1040
|
+
status: dict[str, Any] = {
|
|
1041
|
+
"running": is_daemon_running(),
|
|
1042
|
+
"pid_file_exists": PID_FILE.exists(),
|
|
1043
|
+
"status_file_exists": STATUS_FILE.exists(),
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if PID_FILE.exists():
|
|
1047
|
+
try:
|
|
1048
|
+
with open(PID_FILE) as f:
|
|
1049
|
+
status["pid"] = int(f.read().strip())
|
|
1050
|
+
except KeyboardInterrupt:
|
|
1051
|
+
_thread.interrupt_main()
|
|
1052
|
+
raise
|
|
1053
|
+
except Exception:
|
|
1054
|
+
status["pid"] = None
|
|
1055
|
+
|
|
1056
|
+
if STATUS_FILE.exists():
|
|
1057
|
+
daemon_status = read_status_file()
|
|
1058
|
+
# Convert DaemonStatus to dict for JSON serialization
|
|
1059
|
+
status["current_status"] = daemon_status.to_dict()
|
|
1060
|
+
|
|
1061
|
+
return status
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def get_lock_status() -> dict[str, Any]:
|
|
1065
|
+
"""Get current lock status from the daemon.
|
|
1066
|
+
|
|
1067
|
+
Reads the daemon status file and extracts lock information.
|
|
1068
|
+
This shows which ports and projects have active locks and who holds them.
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
Dictionary with lock status information:
|
|
1072
|
+
- port_locks: Dict of port -> lock info
|
|
1073
|
+
- project_locks: Dict of project -> lock info
|
|
1074
|
+
- stale_locks: List of locks that appear to be stale
|
|
1075
|
+
|
|
1076
|
+
Example:
|
|
1077
|
+
>>> status = get_lock_status()
|
|
1078
|
+
>>> for port, info in status["port_locks"].items():
|
|
1079
|
+
... if info.get("is_held"):
|
|
1080
|
+
... print(f"Port {port} locked by: {info.get('holder_description')}")
|
|
1081
|
+
"""
|
|
1082
|
+
status = read_status_file()
|
|
1083
|
+
locks = status.locks if hasattr(status, "locks") and status.locks else {}
|
|
1084
|
+
|
|
1085
|
+
# Extract stale locks
|
|
1086
|
+
stale_locks: list[dict[str, Any]] = []
|
|
1087
|
+
|
|
1088
|
+
port_locks = locks.get("port_locks", {})
|
|
1089
|
+
for port, info in port_locks.items():
|
|
1090
|
+
if isinstance(info, dict) and info.get("is_stale"):
|
|
1091
|
+
stale_locks.append(
|
|
1092
|
+
{
|
|
1093
|
+
"type": "port",
|
|
1094
|
+
"resource": port,
|
|
1095
|
+
"holder": info.get("holder_description"),
|
|
1096
|
+
"hold_duration": info.get("hold_duration"),
|
|
1097
|
+
}
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
project_locks = locks.get("project_locks", {})
|
|
1101
|
+
for project, info in project_locks.items():
|
|
1102
|
+
if isinstance(info, dict) and info.get("is_stale"):
|
|
1103
|
+
stale_locks.append(
|
|
1104
|
+
{
|
|
1105
|
+
"type": "project",
|
|
1106
|
+
"resource": project,
|
|
1107
|
+
"holder": info.get("holder_description"),
|
|
1108
|
+
"hold_duration": info.get("hold_duration"),
|
|
1109
|
+
}
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
"port_locks": port_locks,
|
|
1114
|
+
"project_locks": project_locks,
|
|
1115
|
+
"stale_locks": stale_locks,
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def request_clear_stale_locks() -> bool:
|
|
1120
|
+
"""Request the daemon to clear stale locks.
|
|
1121
|
+
|
|
1122
|
+
Sends a signal to the daemon to force-release any locks that have been
|
|
1123
|
+
held beyond their timeout. This is useful when operations have hung or
|
|
1124
|
+
crashed without properly releasing their locks.
|
|
1125
|
+
|
|
1126
|
+
Returns:
|
|
1127
|
+
True if signal was sent, False if daemon not running
|
|
1128
|
+
|
|
1129
|
+
Note:
|
|
1130
|
+
The daemon checks for stale locks periodically (every 60 seconds).
|
|
1131
|
+
This function triggers an immediate check by writing a signal file.
|
|
1132
|
+
The actual clearing happens on the daemon side.
|
|
1133
|
+
"""
|
|
1134
|
+
if not is_daemon_running():
|
|
1135
|
+
print("Daemon is not running")
|
|
1136
|
+
return False
|
|
1137
|
+
|
|
1138
|
+
# Create signal file to trigger stale lock cleanup
|
|
1139
|
+
signal_file = DAEMON_DIR / "clear_stale_locks.signal"
|
|
1140
|
+
DAEMON_DIR.mkdir(parents=True, exist_ok=True)
|
|
1141
|
+
signal_file.touch()
|
|
1142
|
+
|
|
1143
|
+
print("Signal sent to daemon to clear stale locks")
|
|
1144
|
+
print("Note: Check status with 'fbuild daemon status' to see results")
|
|
1145
|
+
return True
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def display_lock_status() -> None:
|
|
1149
|
+
"""Display current lock status in a human-readable format."""
|
|
1150
|
+
if not is_daemon_running():
|
|
1151
|
+
print("Daemon is not running - no active locks")
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
lock_status = get_lock_status()
|
|
1155
|
+
|
|
1156
|
+
print("\n=== Lock Status ===\n")
|
|
1157
|
+
|
|
1158
|
+
# Port locks
|
|
1159
|
+
port_locks = lock_status.get("port_locks", {})
|
|
1160
|
+
if port_locks:
|
|
1161
|
+
print("Port Locks:")
|
|
1162
|
+
for port, info in port_locks.items():
|
|
1163
|
+
if isinstance(info, dict):
|
|
1164
|
+
held = info.get("is_held", False)
|
|
1165
|
+
stale = info.get("is_stale", False)
|
|
1166
|
+
holder = info.get("holder_description", "unknown")
|
|
1167
|
+
duration = info.get("hold_duration")
|
|
1168
|
+
|
|
1169
|
+
status_str = "FREE"
|
|
1170
|
+
if held:
|
|
1171
|
+
status_str = "STALE" if stale else "HELD"
|
|
1172
|
+
|
|
1173
|
+
duration_str = f" ({duration:.1f}s)" if duration else ""
|
|
1174
|
+
holder_str = f" by {holder}" if held else ""
|
|
1175
|
+
|
|
1176
|
+
print(f" {port}: {status_str}{holder_str}{duration_str}")
|
|
1177
|
+
else:
|
|
1178
|
+
print("Port Locks: (none)")
|
|
1179
|
+
|
|
1180
|
+
# Project locks
|
|
1181
|
+
project_locks = lock_status.get("project_locks", {})
|
|
1182
|
+
if project_locks:
|
|
1183
|
+
print("\nProject Locks:")
|
|
1184
|
+
for project, info in project_locks.items():
|
|
1185
|
+
if isinstance(info, dict):
|
|
1186
|
+
held = info.get("is_held", False)
|
|
1187
|
+
stale = info.get("is_stale", False)
|
|
1188
|
+
holder = info.get("holder_description", "unknown")
|
|
1189
|
+
duration = info.get("hold_duration")
|
|
1190
|
+
|
|
1191
|
+
status_str = "FREE"
|
|
1192
|
+
if held:
|
|
1193
|
+
status_str = "STALE" if stale else "HELD"
|
|
1194
|
+
|
|
1195
|
+
duration_str = f" ({duration:.1f}s)" if duration else ""
|
|
1196
|
+
holder_str = f" by {holder}" if held else ""
|
|
1197
|
+
|
|
1198
|
+
# Truncate long project paths
|
|
1199
|
+
display_project = project
|
|
1200
|
+
if len(project) > 50:
|
|
1201
|
+
display_project = "..." + project[-47:]
|
|
1202
|
+
|
|
1203
|
+
print(f" {display_project}: {status_str}{holder_str}{duration_str}")
|
|
1204
|
+
else:
|
|
1205
|
+
print("\nProject Locks: (none)")
|
|
1206
|
+
|
|
1207
|
+
# Stale locks warning
|
|
1208
|
+
stale_locks = lock_status.get("stale_locks", [])
|
|
1209
|
+
if stale_locks:
|
|
1210
|
+
print(f"\n⚠️ Found {len(stale_locks)} stale lock(s)!")
|
|
1211
|
+
print(" Use 'fbuild daemon clear-locks' to force-release them")
|
|
1212
|
+
|
|
1213
|
+
print()
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def list_all_daemons() -> list[dict[str, Any]]:
|
|
1217
|
+
"""List all running fbuild daemon instances by scanning processes.
|
|
1218
|
+
|
|
1219
|
+
This function scans all running processes to find fbuild daemons,
|
|
1220
|
+
which is useful for detecting multiple daemon instances that may
|
|
1221
|
+
have been started due to race conditions or startup errors.
|
|
1222
|
+
|
|
1223
|
+
Returns:
|
|
1224
|
+
List of dictionaries with daemon info:
|
|
1225
|
+
- pid: Process ID
|
|
1226
|
+
- cmdline: Command line arguments
|
|
1227
|
+
- uptime: Time since process started (seconds)
|
|
1228
|
+
- is_primary: True if this matches the PID file (primary daemon)
|
|
1229
|
+
|
|
1230
|
+
Example:
|
|
1231
|
+
>>> daemons = list_all_daemons()
|
|
1232
|
+
>>> for d in daemons:
|
|
1233
|
+
... print(f"PID {d['pid']}: uptime {d['uptime']:.1f}s")
|
|
1234
|
+
"""
|
|
1235
|
+
daemons: list[dict[str, Any]] = []
|
|
1236
|
+
|
|
1237
|
+
# Get primary daemon PID from PID file
|
|
1238
|
+
primary_pid = None
|
|
1239
|
+
if PID_FILE.exists():
|
|
1240
|
+
try:
|
|
1241
|
+
with open(PID_FILE) as f:
|
|
1242
|
+
primary_pid = int(f.read().strip())
|
|
1243
|
+
except (ValueError, OSError):
|
|
1244
|
+
pass
|
|
1245
|
+
|
|
1246
|
+
for proc in psutil.process_iter(["pid", "cmdline", "create_time", "name"]):
|
|
1247
|
+
try:
|
|
1248
|
+
cmdline = proc.info.get("cmdline")
|
|
1249
|
+
proc_name = proc.info.get("name", "")
|
|
1250
|
+
if not cmdline:
|
|
1251
|
+
continue
|
|
1252
|
+
|
|
1253
|
+
# Skip non-Python processes
|
|
1254
|
+
if not proc_name.lower().startswith("python"):
|
|
1255
|
+
continue
|
|
1256
|
+
|
|
1257
|
+
# Detect fbuild daemon processes
|
|
1258
|
+
# Look for patterns like "python daemon.py" in fbuild package
|
|
1259
|
+
is_daemon = False
|
|
1260
|
+
|
|
1261
|
+
# Check for direct daemon.py execution from fbuild package
|
|
1262
|
+
# Must end with daemon.py and have fbuild in the path
|
|
1263
|
+
for arg in cmdline:
|
|
1264
|
+
if arg.endswith("daemon.py") and "fbuild" in arg.lower():
|
|
1265
|
+
is_daemon = True
|
|
1266
|
+
break
|
|
1267
|
+
|
|
1268
|
+
# Check for python -m fbuild.daemon.daemon execution
|
|
1269
|
+
if not is_daemon and "-m" in cmdline:
|
|
1270
|
+
for i, arg in enumerate(cmdline):
|
|
1271
|
+
if arg == "-m" and i + 1 < len(cmdline):
|
|
1272
|
+
module = cmdline[i + 1]
|
|
1273
|
+
if module in ("fbuild.daemon.daemon", "fbuild.daemon"):
|
|
1274
|
+
is_daemon = True
|
|
1275
|
+
break
|
|
1276
|
+
|
|
1277
|
+
if is_daemon:
|
|
1278
|
+
pid = proc.info["pid"]
|
|
1279
|
+
create_time = proc.info.get("create_time", time.time())
|
|
1280
|
+
daemons.append(
|
|
1281
|
+
{
|
|
1282
|
+
"pid": pid,
|
|
1283
|
+
"cmdline": cmdline,
|
|
1284
|
+
"uptime": time.time() - create_time,
|
|
1285
|
+
"is_primary": pid == primary_pid,
|
|
1286
|
+
}
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
1290
|
+
continue
|
|
1291
|
+
|
|
1292
|
+
return daemons
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def force_kill_daemon(pid: int) -> bool:
|
|
1296
|
+
"""Force kill a daemon process by PID using SIGKILL.
|
|
1297
|
+
|
|
1298
|
+
This is a forceful termination that doesn't give the daemon
|
|
1299
|
+
time to clean up. Use graceful_kill_daemon() when possible.
|
|
1300
|
+
|
|
1301
|
+
Args:
|
|
1302
|
+
pid: Process ID to kill
|
|
1303
|
+
|
|
1304
|
+
Returns:
|
|
1305
|
+
True if process was killed, False if it didn't exist
|
|
1306
|
+
|
|
1307
|
+
Example:
|
|
1308
|
+
>>> if force_kill_daemon(12345):
|
|
1309
|
+
... print("Daemon killed")
|
|
1310
|
+
"""
|
|
1311
|
+
try:
|
|
1312
|
+
proc = psutil.Process(pid)
|
|
1313
|
+
proc.kill() # SIGKILL on Unix, TerminateProcess on Windows
|
|
1314
|
+
proc.wait(timeout=5)
|
|
1315
|
+
return True
|
|
1316
|
+
except psutil.NoSuchProcess:
|
|
1317
|
+
return False
|
|
1318
|
+
except psutil.TimeoutExpired:
|
|
1319
|
+
# Process didn't die even with SIGKILL - unusual but handle it
|
|
1320
|
+
return True
|
|
1321
|
+
except psutil.AccessDenied:
|
|
1322
|
+
print(f"Access denied: cannot kill process {pid}")
|
|
1323
|
+
return False
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def graceful_kill_daemon(pid: int, timeout: int = 10) -> bool:
|
|
1327
|
+
"""Gracefully terminate a daemon process with fallback to force kill.
|
|
1328
|
+
|
|
1329
|
+
Sends SIGTERM first to allow cleanup, then SIGKILL if the process
|
|
1330
|
+
doesn't exit within the timeout period.
|
|
1331
|
+
|
|
1332
|
+
Args:
|
|
1333
|
+
pid: Process ID to terminate
|
|
1334
|
+
timeout: Seconds to wait before force killing (default: 10)
|
|
1335
|
+
|
|
1336
|
+
Returns:
|
|
1337
|
+
True if process was terminated, False if it didn't exist
|
|
1338
|
+
|
|
1339
|
+
Example:
|
|
1340
|
+
>>> if graceful_kill_daemon(12345, timeout=5):
|
|
1341
|
+
... print("Daemon terminated gracefully")
|
|
1342
|
+
"""
|
|
1343
|
+
try:
|
|
1344
|
+
proc = psutil.Process(pid)
|
|
1345
|
+
proc.terminate() # SIGTERM on Unix, TerminateProcess on Windows
|
|
1346
|
+
|
|
1347
|
+
try:
|
|
1348
|
+
proc.wait(timeout=timeout)
|
|
1349
|
+
return True
|
|
1350
|
+
except psutil.TimeoutExpired:
|
|
1351
|
+
# Process didn't exit gracefully - force kill
|
|
1352
|
+
print(f"Process {pid} didn't exit gracefully, force killing...")
|
|
1353
|
+
proc.kill()
|
|
1354
|
+
proc.wait(timeout=5)
|
|
1355
|
+
return True
|
|
1356
|
+
|
|
1357
|
+
except psutil.NoSuchProcess:
|
|
1358
|
+
return False
|
|
1359
|
+
except psutil.AccessDenied:
|
|
1360
|
+
print(f"Access denied: cannot terminate process {pid}")
|
|
1361
|
+
return False
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def kill_all_daemons(force: bool = False) -> int:
|
|
1365
|
+
"""Kill all running daemon instances.
|
|
1366
|
+
|
|
1367
|
+
Useful when multiple daemons have started due to race conditions
|
|
1368
|
+
or when the daemon system is in an inconsistent state.
|
|
1369
|
+
|
|
1370
|
+
Args:
|
|
1371
|
+
force: If True, use SIGKILL immediately. If False, try SIGTERM first.
|
|
1372
|
+
|
|
1373
|
+
Returns:
|
|
1374
|
+
Number of daemons killed
|
|
1375
|
+
|
|
1376
|
+
Example:
|
|
1377
|
+
>>> killed = kill_all_daemons(force=False)
|
|
1378
|
+
>>> print(f"Killed {killed} daemon(s)")
|
|
1379
|
+
"""
|
|
1380
|
+
killed = 0
|
|
1381
|
+
daemons = list_all_daemons()
|
|
1382
|
+
|
|
1383
|
+
if not daemons:
|
|
1384
|
+
return 0
|
|
1385
|
+
|
|
1386
|
+
for daemon in daemons:
|
|
1387
|
+
pid = daemon["pid"]
|
|
1388
|
+
if force:
|
|
1389
|
+
if force_kill_daemon(pid):
|
|
1390
|
+
killed += 1
|
|
1391
|
+
print(f"Force killed daemon (PID {pid})")
|
|
1392
|
+
else:
|
|
1393
|
+
if graceful_kill_daemon(pid):
|
|
1394
|
+
killed += 1
|
|
1395
|
+
print(f"Gracefully terminated daemon (PID {pid})")
|
|
1396
|
+
|
|
1397
|
+
# Clean up PID file if we killed any daemons
|
|
1398
|
+
if killed > 0 and PID_FILE.exists():
|
|
1399
|
+
try:
|
|
1400
|
+
PID_FILE.unlink()
|
|
1401
|
+
except OSError:
|
|
1402
|
+
pass
|
|
1403
|
+
|
|
1404
|
+
return killed
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
def display_daemon_list() -> None:
|
|
1408
|
+
"""Display all running daemon instances in a human-readable format."""
|
|
1409
|
+
daemons = list_all_daemons()
|
|
1410
|
+
|
|
1411
|
+
if not daemons:
|
|
1412
|
+
print("No fbuild daemon instances found")
|
|
1413
|
+
return
|
|
1414
|
+
|
|
1415
|
+
print(f"\n=== Running fbuild Daemons ({len(daemons)} found) ===\n")
|
|
1416
|
+
|
|
1417
|
+
for daemon in daemons:
|
|
1418
|
+
pid = daemon["pid"]
|
|
1419
|
+
uptime = daemon["uptime"]
|
|
1420
|
+
is_primary = daemon["is_primary"]
|
|
1421
|
+
|
|
1422
|
+
# Format uptime
|
|
1423
|
+
if uptime < 60:
|
|
1424
|
+
uptime_str = f"{uptime:.1f}s"
|
|
1425
|
+
elif uptime < 3600:
|
|
1426
|
+
uptime_str = f"{uptime / 60:.1f}m"
|
|
1427
|
+
else:
|
|
1428
|
+
uptime_str = f"{uptime / 3600:.1f}h"
|
|
1429
|
+
|
|
1430
|
+
primary_str = " (PRIMARY)" if is_primary else " (ORPHAN)"
|
|
1431
|
+
print(f" PID {pid}: uptime {uptime_str}{primary_str}")
|
|
1432
|
+
|
|
1433
|
+
print()
|
|
1434
|
+
|
|
1435
|
+
# Warn about multiple daemons
|
|
1436
|
+
if len(daemons) > 1:
|
|
1437
|
+
print("⚠️ Multiple daemon instances detected!")
|
|
1438
|
+
print(" This can cause lock conflicts and unexpected behavior.")
|
|
1439
|
+
print(" Use 'fbuild daemon kill-all' to clean up, then restart.")
|
|
1440
|
+
print()
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def main() -> int:
|
|
1444
|
+
"""Command-line interface for client."""
|
|
1445
|
+
import argparse
|
|
1446
|
+
|
|
1447
|
+
parser = argparse.ArgumentParser(description="fbuild Daemon Client")
|
|
1448
|
+
parser.add_argument("--status", action="store_true", help="Show daemon status")
|
|
1449
|
+
parser.add_argument("--stop", action="store_true", help="Stop the daemon")
|
|
1450
|
+
parser.add_argument("--locks", action="store_true", help="Show lock status")
|
|
1451
|
+
parser.add_argument("--clear-locks", action="store_true", help="Clear stale locks")
|
|
1452
|
+
parser.add_argument("--list", action="store_true", help="List all daemon instances")
|
|
1453
|
+
parser.add_argument("--kill", type=int, metavar="PID", help="Kill specific daemon by PID")
|
|
1454
|
+
parser.add_argument("--kill-all", action="store_true", help="Kill all daemon instances")
|
|
1455
|
+
parser.add_argument("--force", action="store_true", help="Force kill (with --kill or --kill-all)")
|
|
1456
|
+
|
|
1457
|
+
args = parser.parse_args()
|
|
1458
|
+
|
|
1459
|
+
if args.status:
|
|
1460
|
+
status = get_daemon_status()
|
|
1461
|
+
print("Daemon Status:")
|
|
1462
|
+
print(json.dumps(status, indent=2))
|
|
1463
|
+
return 0
|
|
1464
|
+
|
|
1465
|
+
if args.stop:
|
|
1466
|
+
return 0 if stop_daemon() else 1
|
|
1467
|
+
|
|
1468
|
+
if args.locks:
|
|
1469
|
+
display_lock_status()
|
|
1470
|
+
return 0
|
|
1471
|
+
|
|
1472
|
+
if args.clear_locks:
|
|
1473
|
+
return 0 if request_clear_stale_locks() else 1
|
|
1474
|
+
|
|
1475
|
+
if args.list:
|
|
1476
|
+
display_daemon_list()
|
|
1477
|
+
return 0
|
|
1478
|
+
|
|
1479
|
+
if args.kill:
|
|
1480
|
+
if args.force:
|
|
1481
|
+
success = force_kill_daemon(args.kill)
|
|
1482
|
+
else:
|
|
1483
|
+
success = graceful_kill_daemon(args.kill)
|
|
1484
|
+
if success:
|
|
1485
|
+
print(f"Daemon (PID {args.kill}) terminated")
|
|
1486
|
+
return 0
|
|
1487
|
+
else:
|
|
1488
|
+
print(f"Failed to terminate daemon (PID {args.kill}) - process may not exist")
|
|
1489
|
+
return 1
|
|
1490
|
+
|
|
1491
|
+
if args.kill_all:
|
|
1492
|
+
killed = kill_all_daemons(force=args.force)
|
|
1493
|
+
print(f"Killed {killed} daemon instance(s)")
|
|
1494
|
+
return 0
|
|
1495
|
+
|
|
1496
|
+
parser.print_help()
|
|
1497
|
+
return 1
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
if __name__ == "__main__":
|
|
1501
|
+
try:
|
|
1502
|
+
sys.exit(main())
|
|
1503
|
+
except KeyboardInterrupt: # noqa: KBI002
|
|
1504
|
+
print("\nInterrupted by user")
|
|
1505
|
+
sys.exit(130)
|