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.
Files changed (121) hide show
  1. fbuild/__init__.py +390 -0
  2. fbuild/assets/example.txt +1 -0
  3. fbuild/build/__init__.py +117 -0
  4. fbuild/build/archive_creator.py +186 -0
  5. fbuild/build/binary_generator.py +444 -0
  6. fbuild/build/build_component_factory.py +131 -0
  7. fbuild/build/build_info_generator.py +624 -0
  8. fbuild/build/build_state.py +325 -0
  9. fbuild/build/build_utils.py +93 -0
  10. fbuild/build/compilation_executor.py +422 -0
  11. fbuild/build/compiler.py +165 -0
  12. fbuild/build/compiler_avr.py +574 -0
  13. fbuild/build/configurable_compiler.py +664 -0
  14. fbuild/build/configurable_linker.py +637 -0
  15. fbuild/build/flag_builder.py +214 -0
  16. fbuild/build/library_dependency_processor.py +185 -0
  17. fbuild/build/linker.py +708 -0
  18. fbuild/build/orchestrator.py +67 -0
  19. fbuild/build/orchestrator_avr.py +651 -0
  20. fbuild/build/orchestrator_esp32.py +878 -0
  21. fbuild/build/orchestrator_rp2040.py +719 -0
  22. fbuild/build/orchestrator_stm32.py +696 -0
  23. fbuild/build/orchestrator_teensy.py +580 -0
  24. fbuild/build/source_compilation_orchestrator.py +218 -0
  25. fbuild/build/source_scanner.py +516 -0
  26. fbuild/cli.py +717 -0
  27. fbuild/cli_utils.py +314 -0
  28. fbuild/config/__init__.py +16 -0
  29. fbuild/config/board_config.py +542 -0
  30. fbuild/config/board_loader.py +92 -0
  31. fbuild/config/ini_parser.py +369 -0
  32. fbuild/config/mcu_specs.py +88 -0
  33. fbuild/daemon/__init__.py +42 -0
  34. fbuild/daemon/async_client.py +531 -0
  35. fbuild/daemon/client.py +1505 -0
  36. fbuild/daemon/compilation_queue.py +293 -0
  37. fbuild/daemon/configuration_lock.py +865 -0
  38. fbuild/daemon/daemon.py +585 -0
  39. fbuild/daemon/daemon_context.py +293 -0
  40. fbuild/daemon/error_collector.py +263 -0
  41. fbuild/daemon/file_cache.py +332 -0
  42. fbuild/daemon/firmware_ledger.py +546 -0
  43. fbuild/daemon/lock_manager.py +508 -0
  44. fbuild/daemon/logging_utils.py +149 -0
  45. fbuild/daemon/messages.py +957 -0
  46. fbuild/daemon/operation_registry.py +288 -0
  47. fbuild/daemon/port_state_manager.py +249 -0
  48. fbuild/daemon/process_tracker.py +366 -0
  49. fbuild/daemon/processors/__init__.py +18 -0
  50. fbuild/daemon/processors/build_processor.py +248 -0
  51. fbuild/daemon/processors/deploy_processor.py +664 -0
  52. fbuild/daemon/processors/install_deps_processor.py +431 -0
  53. fbuild/daemon/processors/locking_processor.py +777 -0
  54. fbuild/daemon/processors/monitor_processor.py +285 -0
  55. fbuild/daemon/request_processor.py +457 -0
  56. fbuild/daemon/shared_serial.py +819 -0
  57. fbuild/daemon/status_manager.py +238 -0
  58. fbuild/daemon/subprocess_manager.py +316 -0
  59. fbuild/deploy/__init__.py +21 -0
  60. fbuild/deploy/deployer.py +67 -0
  61. fbuild/deploy/deployer_esp32.py +310 -0
  62. fbuild/deploy/docker_utils.py +315 -0
  63. fbuild/deploy/monitor.py +519 -0
  64. fbuild/deploy/qemu_runner.py +603 -0
  65. fbuild/interrupt_utils.py +34 -0
  66. fbuild/ledger/__init__.py +52 -0
  67. fbuild/ledger/board_ledger.py +560 -0
  68. fbuild/output.py +352 -0
  69. fbuild/packages/__init__.py +66 -0
  70. fbuild/packages/archive_utils.py +1098 -0
  71. fbuild/packages/arduino_core.py +412 -0
  72. fbuild/packages/cache.py +256 -0
  73. fbuild/packages/concurrent_manager.py +510 -0
  74. fbuild/packages/downloader.py +518 -0
  75. fbuild/packages/fingerprint.py +423 -0
  76. fbuild/packages/framework_esp32.py +538 -0
  77. fbuild/packages/framework_rp2040.py +349 -0
  78. fbuild/packages/framework_stm32.py +459 -0
  79. fbuild/packages/framework_teensy.py +346 -0
  80. fbuild/packages/github_utils.py +96 -0
  81. fbuild/packages/header_trampoline_cache.py +394 -0
  82. fbuild/packages/library_compiler.py +203 -0
  83. fbuild/packages/library_manager.py +549 -0
  84. fbuild/packages/library_manager_esp32.py +725 -0
  85. fbuild/packages/package.py +163 -0
  86. fbuild/packages/platform_esp32.py +383 -0
  87. fbuild/packages/platform_rp2040.py +400 -0
  88. fbuild/packages/platform_stm32.py +581 -0
  89. fbuild/packages/platform_teensy.py +312 -0
  90. fbuild/packages/platform_utils.py +131 -0
  91. fbuild/packages/platformio_registry.py +369 -0
  92. fbuild/packages/sdk_utils.py +231 -0
  93. fbuild/packages/toolchain.py +436 -0
  94. fbuild/packages/toolchain_binaries.py +196 -0
  95. fbuild/packages/toolchain_esp32.py +489 -0
  96. fbuild/packages/toolchain_metadata.py +185 -0
  97. fbuild/packages/toolchain_rp2040.py +436 -0
  98. fbuild/packages/toolchain_stm32.py +417 -0
  99. fbuild/packages/toolchain_teensy.py +404 -0
  100. fbuild/platform_configs/esp32.json +150 -0
  101. fbuild/platform_configs/esp32c2.json +144 -0
  102. fbuild/platform_configs/esp32c3.json +143 -0
  103. fbuild/platform_configs/esp32c5.json +151 -0
  104. fbuild/platform_configs/esp32c6.json +151 -0
  105. fbuild/platform_configs/esp32p4.json +149 -0
  106. fbuild/platform_configs/esp32s3.json +151 -0
  107. fbuild/platform_configs/imxrt1062.json +56 -0
  108. fbuild/platform_configs/rp2040.json +70 -0
  109. fbuild/platform_configs/rp2350.json +76 -0
  110. fbuild/platform_configs/stm32f1.json +59 -0
  111. fbuild/platform_configs/stm32f4.json +63 -0
  112. fbuild/py.typed +0 -0
  113. fbuild-1.2.8.dist-info/METADATA +468 -0
  114. fbuild-1.2.8.dist-info/RECORD +121 -0
  115. fbuild-1.2.8.dist-info/WHEEL +5 -0
  116. fbuild-1.2.8.dist-info/entry_points.txt +5 -0
  117. fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
  118. fbuild-1.2.8.dist-info/top_level.txt +2 -0
  119. fbuild_lint/__init__.py +0 -0
  120. fbuild_lint/ruff_plugins/__init__.py +0 -0
  121. fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
@@ -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)