fbuild 1.2.8__py3-none-any.whl → 1.2.15__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 (47) hide show
  1. fbuild/__init__.py +5 -1
  2. fbuild/build/configurable_compiler.py +49 -6
  3. fbuild/build/configurable_linker.py +14 -9
  4. fbuild/build/orchestrator_esp32.py +6 -3
  5. fbuild/build/orchestrator_rp2040.py +6 -2
  6. fbuild/cli.py +300 -5
  7. fbuild/config/ini_parser.py +13 -1
  8. fbuild/daemon/__init__.py +11 -0
  9. fbuild/daemon/async_client.py +5 -4
  10. fbuild/daemon/async_client_lib.py +1543 -0
  11. fbuild/daemon/async_protocol.py +825 -0
  12. fbuild/daemon/async_server.py +2100 -0
  13. fbuild/daemon/client.py +425 -13
  14. fbuild/daemon/configuration_lock.py +13 -13
  15. fbuild/daemon/connection.py +508 -0
  16. fbuild/daemon/connection_registry.py +579 -0
  17. fbuild/daemon/daemon.py +517 -164
  18. fbuild/daemon/daemon_context.py +72 -1
  19. fbuild/daemon/device_discovery.py +477 -0
  20. fbuild/daemon/device_manager.py +821 -0
  21. fbuild/daemon/error_collector.py +263 -263
  22. fbuild/daemon/file_cache.py +332 -332
  23. fbuild/daemon/firmware_ledger.py +46 -123
  24. fbuild/daemon/lock_manager.py +508 -508
  25. fbuild/daemon/messages.py +431 -0
  26. fbuild/daemon/operation_registry.py +288 -288
  27. fbuild/daemon/processors/build_processor.py +34 -1
  28. fbuild/daemon/processors/deploy_processor.py +1 -3
  29. fbuild/daemon/processors/locking_processor.py +7 -7
  30. fbuild/daemon/request_processor.py +457 -457
  31. fbuild/daemon/shared_serial.py +7 -7
  32. fbuild/daemon/status_manager.py +238 -238
  33. fbuild/daemon/subprocess_manager.py +316 -316
  34. fbuild/deploy/docker_utils.py +182 -2
  35. fbuild/deploy/monitor.py +1 -1
  36. fbuild/deploy/qemu_runner.py +71 -13
  37. fbuild/ledger/board_ledger.py +46 -122
  38. fbuild/output.py +238 -2
  39. fbuild/packages/library_compiler.py +15 -5
  40. fbuild/packages/library_manager.py +12 -6
  41. fbuild-1.2.15.dist-info/METADATA +569 -0
  42. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/RECORD +46 -39
  43. fbuild-1.2.8.dist-info/METADATA +0 -468
  44. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/WHEEL +0 -0
  45. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/entry_points.txt +0 -0
  46. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/licenses/LICENSE +0 -0
  47. {fbuild-1.2.8.dist-info → fbuild-1.2.15.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,579 @@
1
+ """
2
+ Server-side connection registry for tracking active daemon client connections.
3
+
4
+ This module provides the daemon-side tracking of all connected clients,
5
+ their state, and platform slot assignments. It is used by the daemon process
6
+ to manage concurrent client connections and coordinate resource allocation.
7
+
8
+ Key concepts:
9
+ - ConnectionState: Server-side state for a single client connection
10
+ - PlatformSlot: A platform-specific resource slot (e.g., esp32s3, esp32c6)
11
+ - ConnectionRegistry: Thread-safe registry managing all connections and slots
12
+ """
13
+
14
+ import logging
15
+ import threading
16
+ import time
17
+ from dataclasses import asdict, dataclass
18
+ from typing import Any
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class ConnectionState:
25
+ """Server-side state for a single client connection.
26
+
27
+ This dataclass tracks the state of a connected client as seen by the daemon.
28
+ Each connection has a unique UUID and tracks project/environment context,
29
+ heartbeat status, and any held platform slots.
30
+
31
+ Attributes:
32
+ connection_id: UUID for this connection
33
+ project_dir: Client's project directory
34
+ environment: Build environment (e.g., "esp32dev", "uno")
35
+ platform: Target platform (e.g., "esp32s3", "esp32c6", "uno")
36
+ connected_at: Connection timestamp (Unix time)
37
+ last_heartbeat: Last heartbeat received (Unix time)
38
+ firmware_uuid: UUID of current firmware (if deployed)
39
+ slot_held: Platform slot currently held (if any)
40
+ client_pid: Client process ID
41
+ client_hostname: Client hostname
42
+ client_version: Client version string
43
+ """
44
+
45
+ connection_id: str
46
+ project_dir: str
47
+ environment: str
48
+ platform: str
49
+ connected_at: float
50
+ last_heartbeat: float
51
+ firmware_uuid: str | None
52
+ slot_held: str | None
53
+ client_pid: int
54
+ client_hostname: str
55
+ client_version: str
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ """Convert to dictionary for JSON serialization."""
59
+ return asdict(self)
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, Any]) -> "ConnectionState":
63
+ """Create ConnectionState from dictionary."""
64
+ return cls(
65
+ connection_id=data["connection_id"],
66
+ project_dir=data["project_dir"],
67
+ environment=data["environment"],
68
+ platform=data["platform"],
69
+ connected_at=data["connected_at"],
70
+ last_heartbeat=data["last_heartbeat"],
71
+ firmware_uuid=data.get("firmware_uuid"),
72
+ slot_held=data.get("slot_held"),
73
+ client_pid=data["client_pid"],
74
+ client_hostname=data["client_hostname"],
75
+ client_version=data["client_version"],
76
+ )
77
+
78
+ def is_stale(self, timeout_seconds: float = 30.0) -> bool:
79
+ """Check if this connection has missed heartbeats.
80
+
81
+ Args:
82
+ timeout_seconds: Maximum allowed time since last heartbeat
83
+
84
+ Returns:
85
+ True if the connection is stale (heartbeat timeout exceeded)
86
+ """
87
+ return (time.time() - self.last_heartbeat) > timeout_seconds
88
+
89
+ def get_age_seconds(self) -> float:
90
+ """Get how long this connection has been active.
91
+
92
+ Returns:
93
+ Connection age in seconds
94
+ """
95
+ return time.time() - self.connected_at
96
+
97
+ def get_idle_seconds(self) -> float:
98
+ """Get how long since last heartbeat.
99
+
100
+ Returns:
101
+ Seconds since last heartbeat
102
+ """
103
+ return time.time() - self.last_heartbeat
104
+
105
+
106
+ @dataclass
107
+ class PlatformSlot:
108
+ """A platform slot on the daemon (e.g., esp32s3, esp32c6, uno).
109
+
110
+ Platform slots represent exclusive access to build/deploy for a specific
111
+ platform. Only one connection can hold a slot at a time, ensuring that
112
+ concurrent operations on the same platform are serialized.
113
+
114
+ Attributes:
115
+ platform: Platform identifier (e.g., "esp32s3", "esp32c6", "uno")
116
+ current_connection_id: UUID of connection holding slot (None if free)
117
+ current_firmware_uuid: UUID of deployed firmware (None if none)
118
+ last_build_hash: Hash of last successful build (for incremental builds)
119
+ locked_at: Timestamp when slot was acquired (None if free)
120
+ """
121
+
122
+ platform: str
123
+ current_connection_id: str | None = None
124
+ current_firmware_uuid: str | None = None
125
+ last_build_hash: str | None = None
126
+ locked_at: float | None = None
127
+
128
+ def to_dict(self) -> dict[str, Any]:
129
+ """Convert to dictionary for JSON serialization."""
130
+ return asdict(self)
131
+
132
+ @classmethod
133
+ def from_dict(cls, data: dict[str, Any]) -> "PlatformSlot":
134
+ """Create PlatformSlot from dictionary."""
135
+ return cls(
136
+ platform=data["platform"],
137
+ current_connection_id=data.get("current_connection_id"),
138
+ current_firmware_uuid=data.get("current_firmware_uuid"),
139
+ last_build_hash=data.get("last_build_hash"),
140
+ locked_at=data.get("locked_at"),
141
+ )
142
+
143
+ def is_free(self) -> bool:
144
+ """Check if this slot is available.
145
+
146
+ Returns:
147
+ True if no connection currently holds this slot
148
+ """
149
+ return self.current_connection_id is None
150
+
151
+ def is_held_by(self, connection_id: str) -> bool:
152
+ """Check if this slot is held by a specific connection.
153
+
154
+ Args:
155
+ connection_id: Connection UUID to check
156
+
157
+ Returns:
158
+ True if the specified connection holds this slot
159
+ """
160
+ return self.current_connection_id == connection_id
161
+
162
+ def get_lock_duration(self) -> float | None:
163
+ """Get how long this slot has been locked.
164
+
165
+ Returns:
166
+ Lock duration in seconds, or None if slot is free
167
+ """
168
+ if self.locked_at is None:
169
+ return None
170
+ return time.time() - self.locked_at
171
+
172
+
173
+ class ConnectionRegistry:
174
+ """Server-side registry of all active client connections.
175
+
176
+ This class manages the state of all connected clients and their platform
177
+ slot assignments. It is thread-safe and uses a single lock for all
178
+ mutations to ensure consistency.
179
+
180
+ The registry supports:
181
+ - Connection lifecycle (register, unregister, heartbeat)
182
+ - Platform slot acquisition and release
183
+ - Stale connection detection and cleanup
184
+ - Firmware UUID tracking per connection
185
+
186
+ Typical usage:
187
+ registry = ConnectionRegistry(heartbeat_timeout=30.0)
188
+
189
+ # Register a new connection
190
+ state = registry.register_connection(
191
+ connection_id="uuid-1234",
192
+ project_dir="/path/to/project",
193
+ environment="esp32dev",
194
+ platform="esp32s3",
195
+ client_pid=12345,
196
+ client_hostname="localhost",
197
+ client_version="1.2.11"
198
+ )
199
+
200
+ # Acquire platform slot
201
+ if registry.acquire_slot("uuid-1234", "esp32s3"):
202
+ # Do work...
203
+ registry.release_slot("uuid-1234")
204
+
205
+ # Cleanup on disconnect
206
+ registry.unregister_connection("uuid-1234")
207
+ """
208
+
209
+ def __init__(self, heartbeat_timeout: float = 30.0) -> None:
210
+ """Initialize the connection registry.
211
+
212
+ Args:
213
+ heartbeat_timeout: Maximum seconds allowed between heartbeats
214
+ before a connection is considered stale. Default is 30 seconds.
215
+ """
216
+ self._lock = threading.Lock()
217
+ self._heartbeat_timeout = heartbeat_timeout
218
+ self._connections: dict[str, ConnectionState] = {}
219
+ self._platform_slots: dict[str, PlatformSlot] = {}
220
+
221
+ @property
222
+ def connections(self) -> dict[str, ConnectionState]:
223
+ """Get a copy of the connections dictionary.
224
+
225
+ Returns:
226
+ Copy of connection_id -> ConnectionState mapping
227
+ """
228
+ with self._lock:
229
+ return dict(self._connections)
230
+
231
+ @property
232
+ def platform_slots(self) -> dict[str, PlatformSlot]:
233
+ """Get a copy of the platform slots dictionary.
234
+
235
+ Returns:
236
+ Copy of platform -> PlatformSlot mapping
237
+ """
238
+ with self._lock:
239
+ return dict(self._platform_slots)
240
+
241
+ def register_connection(
242
+ self,
243
+ connection_id: str,
244
+ project_dir: str,
245
+ environment: str,
246
+ platform: str,
247
+ client_pid: int,
248
+ client_hostname: str,
249
+ client_version: str,
250
+ ) -> ConnectionState:
251
+ """Register a new client connection.
252
+
253
+ Creates a new ConnectionState for the client and adds it to the registry.
254
+ If a connection with the same ID already exists, it will be replaced.
255
+
256
+ Args:
257
+ connection_id: Unique UUID for this connection
258
+ project_dir: Client's project directory
259
+ environment: Build environment name
260
+ platform: Target platform (e.g., "esp32s3")
261
+ client_pid: Client process ID
262
+ client_hostname: Client hostname
263
+ client_version: Client version string
264
+
265
+ Returns:
266
+ The newly created ConnectionState
267
+ """
268
+ now = time.time()
269
+ state = ConnectionState(
270
+ connection_id=connection_id,
271
+ project_dir=project_dir,
272
+ environment=environment,
273
+ platform=platform,
274
+ connected_at=now,
275
+ last_heartbeat=now,
276
+ firmware_uuid=None,
277
+ slot_held=None,
278
+ client_pid=client_pid,
279
+ client_hostname=client_hostname,
280
+ client_version=client_version,
281
+ )
282
+
283
+ with self._lock:
284
+ # If connection already exists, clean up any held resources first
285
+ if connection_id in self._connections:
286
+ logger.warning(f"Re-registering existing connection {connection_id}, cleaning up old state")
287
+ self._release_slot_unlocked(connection_id)
288
+
289
+ self._connections[connection_id] = state
290
+ logger.info(f"Registered connection {connection_id} from {client_hostname} (pid={client_pid})")
291
+
292
+ return state
293
+
294
+ def unregister_connection(self, connection_id: str) -> bool:
295
+ """Unregister a client connection.
296
+
297
+ Removes the connection from the registry and releases any held slots.
298
+
299
+ Args:
300
+ connection_id: UUID of the connection to unregister
301
+
302
+ Returns:
303
+ True if the connection was found and removed, False if not found
304
+ """
305
+ with self._lock:
306
+ if connection_id not in self._connections:
307
+ logger.warning(f"Attempted to unregister unknown connection {connection_id}")
308
+ return False
309
+
310
+ # Release any held slot
311
+ self._release_slot_unlocked(connection_id)
312
+
313
+ # Remove the connection
314
+ state = self._connections.pop(connection_id)
315
+ logger.info(f"Unregistered connection {connection_id} (was connected for {state.get_age_seconds():.1f}s)")
316
+ return True
317
+
318
+ def update_heartbeat(self, connection_id: str) -> bool:
319
+ """Update the heartbeat timestamp for a connection.
320
+
321
+ Args:
322
+ connection_id: UUID of the connection to update
323
+
324
+ Returns:
325
+ True if the connection was found and updated, False if not found
326
+ """
327
+ with self._lock:
328
+ if connection_id not in self._connections:
329
+ logger.debug(f"Heartbeat for unknown connection {connection_id}")
330
+ return False
331
+
332
+ self._connections[connection_id].last_heartbeat = time.time()
333
+ return True
334
+
335
+ def check_stale_connections(self) -> list[str]:
336
+ """Check for connections that have missed heartbeats.
337
+
338
+ Returns:
339
+ List of connection IDs that are stale (heartbeat timeout exceeded)
340
+ """
341
+ stale_ids: list[str] = []
342
+ now = time.time()
343
+
344
+ with self._lock:
345
+ for conn_id, state in self._connections.items():
346
+ if (now - state.last_heartbeat) > self._heartbeat_timeout:
347
+ stale_ids.append(conn_id)
348
+
349
+ return stale_ids
350
+
351
+ def cleanup_stale_connections(self) -> int:
352
+ """Clean up all stale connections.
353
+
354
+ Removes connections that have exceeded the heartbeat timeout and
355
+ releases any slots they were holding.
356
+
357
+ Returns:
358
+ Number of connections that were cleaned up
359
+ """
360
+ stale_ids = self.check_stale_connections()
361
+
362
+ for conn_id in stale_ids:
363
+ with self._lock:
364
+ if conn_id in self._connections:
365
+ state = self._connections[conn_id]
366
+ idle_time = state.get_idle_seconds()
367
+ logger.warning(f"Cleaning up stale connection {conn_id} (idle for {idle_time:.1f}s)")
368
+ self._release_slot_unlocked(conn_id)
369
+ self._connections.pop(conn_id, None)
370
+
371
+ if stale_ids:
372
+ logger.info(f"Cleaned up {len(stale_ids)} stale connection(s)")
373
+
374
+ return len(stale_ids)
375
+
376
+ def acquire_slot(self, connection_id: str, platform: str) -> bool:
377
+ """Acquire a platform slot for a connection.
378
+
379
+ Attempts to acquire exclusive access to a platform slot. If the slot
380
+ is already held by another connection, this will fail.
381
+
382
+ Args:
383
+ connection_id: UUID of the connection requesting the slot
384
+ platform: Platform to acquire (e.g., "esp32s3")
385
+
386
+ Returns:
387
+ True if the slot was acquired, False if unavailable or error
388
+ """
389
+ with self._lock:
390
+ # Verify connection exists
391
+ if connection_id not in self._connections:
392
+ logger.warning(f"Cannot acquire slot for unknown connection {connection_id}")
393
+ return False
394
+
395
+ state = self._connections[connection_id]
396
+
397
+ # Check if connection already holds a slot
398
+ if state.slot_held is not None:
399
+ if state.slot_held == platform:
400
+ # Already holds this slot
401
+ logger.debug(f"Connection {connection_id} already holds slot {platform}")
402
+ return True
403
+ else:
404
+ # Holds a different slot - must release first
405
+ logger.warning(f"Connection {connection_id} already holds slot {state.slot_held}, cannot acquire {platform}")
406
+ return False
407
+
408
+ # Get or create the platform slot
409
+ if platform not in self._platform_slots:
410
+ self._platform_slots[platform] = PlatformSlot(platform=platform)
411
+
412
+ slot = self._platform_slots[platform]
413
+
414
+ # Check if slot is available
415
+ if slot.current_connection_id is not None and slot.current_connection_id != connection_id:
416
+ logger.debug(f"Slot {platform} is held by {slot.current_connection_id}, cannot acquire for {connection_id}")
417
+ return False
418
+
419
+ # Acquire the slot
420
+ slot.current_connection_id = connection_id
421
+ slot.locked_at = time.time()
422
+ state.slot_held = platform
423
+
424
+ logger.info(f"Connection {connection_id} acquired slot {platform}")
425
+ return True
426
+
427
+ def release_slot(self, connection_id: str) -> bool:
428
+ """Release the platform slot held by a connection.
429
+
430
+ Args:
431
+ connection_id: UUID of the connection releasing its slot
432
+
433
+ Returns:
434
+ True if a slot was released, False if no slot was held
435
+ """
436
+ with self._lock:
437
+ return self._release_slot_unlocked(connection_id)
438
+
439
+ def _release_slot_unlocked(self, connection_id: str) -> bool:
440
+ """Internal: Release slot without acquiring lock.
441
+
442
+ Must be called while holding self._lock.
443
+
444
+ Args:
445
+ connection_id: UUID of the connection
446
+
447
+ Returns:
448
+ True if a slot was released
449
+ """
450
+ if connection_id not in self._connections:
451
+ return False
452
+
453
+ state = self._connections[connection_id]
454
+
455
+ if state.slot_held is None:
456
+ return False
457
+
458
+ platform = state.slot_held
459
+ if platform in self._platform_slots:
460
+ slot = self._platform_slots[platform]
461
+ if slot.current_connection_id == connection_id:
462
+ slot.current_connection_id = None
463
+ slot.locked_at = None
464
+ logger.info(f"Connection {connection_id} released slot {platform}")
465
+
466
+ state.slot_held = None
467
+ return True
468
+
469
+ def set_firmware_uuid(self, connection_id: str, firmware_uuid: str) -> bool:
470
+ """Set the firmware UUID for a connection.
471
+
472
+ Also updates the platform slot's firmware UUID if the connection
473
+ holds a slot.
474
+
475
+ Args:
476
+ connection_id: UUID of the connection
477
+ firmware_uuid: UUID of the deployed firmware
478
+
479
+ Returns:
480
+ True if the connection was found and updated, False if not found
481
+ """
482
+ with self._lock:
483
+ if connection_id not in self._connections:
484
+ logger.warning(f"Cannot set firmware UUID for unknown connection {connection_id}")
485
+ return False
486
+
487
+ state = self._connections[connection_id]
488
+ state.firmware_uuid = firmware_uuid
489
+
490
+ # Also update the platform slot if held
491
+ if state.slot_held and state.slot_held in self._platform_slots:
492
+ self._platform_slots[state.slot_held].current_firmware_uuid = firmware_uuid
493
+ logger.debug(f"Updated firmware UUID for slot {state.slot_held}: {firmware_uuid}")
494
+
495
+ return True
496
+
497
+ def get_connection(self, connection_id: str) -> ConnectionState | None:
498
+ """Get the state of a specific connection.
499
+
500
+ Args:
501
+ connection_id: UUID of the connection to retrieve
502
+
503
+ Returns:
504
+ ConnectionState if found, None otherwise
505
+ """
506
+ with self._lock:
507
+ return self._connections.get(connection_id)
508
+
509
+ def get_all_connections(self) -> list[ConnectionState]:
510
+ """Get all active connections.
511
+
512
+ Returns:
513
+ List of all ConnectionState objects
514
+ """
515
+ with self._lock:
516
+ return list(self._connections.values())
517
+
518
+ def get_slot_status(self, platform: str) -> PlatformSlot | None:
519
+ """Get the status of a specific platform slot.
520
+
521
+ Args:
522
+ platform: Platform to query (e.g., "esp32s3")
523
+
524
+ Returns:
525
+ PlatformSlot if it exists, None otherwise
526
+ """
527
+ with self._lock:
528
+ return self._platform_slots.get(platform)
529
+
530
+ def get_all_slots(self) -> dict[str, PlatformSlot]:
531
+ """Get all platform slots.
532
+
533
+ Returns:
534
+ Copy of platform -> PlatformSlot mapping
535
+ """
536
+ with self._lock:
537
+ return dict(self._platform_slots)
538
+
539
+ def release_all_client_resources(self, connection_id: str) -> None:
540
+ """Release all resources held by a client connection.
541
+
542
+ This is called during graceful disconnect or stale connection cleanup.
543
+ It releases any held slots and performs any necessary cleanup.
544
+
545
+ Args:
546
+ connection_id: UUID of the connection to clean up
547
+ """
548
+ with self._lock:
549
+ if connection_id not in self._connections:
550
+ return
551
+
552
+ # Release slot if held
553
+ self._release_slot_unlocked(connection_id)
554
+
555
+ # Clear firmware UUID
556
+ state = self._connections[connection_id]
557
+ state.firmware_uuid = None
558
+
559
+ logger.info(f"Released all resources for connection {connection_id}")
560
+
561
+ def to_dict(self) -> dict[str, Any]:
562
+ """Convert registry state to dictionary for status reporting.
563
+
564
+ Returns:
565
+ Dictionary containing:
566
+ - connections: List of connection state dicts
567
+ - platform_slots: Dict of platform -> slot state dicts
568
+ - connection_count: Number of active connections
569
+ - slot_count: Number of platform slots
570
+ - heartbeat_timeout: Configured heartbeat timeout
571
+ """
572
+ with self._lock:
573
+ return {
574
+ "connections": [state.to_dict() for state in self._connections.values()],
575
+ "platform_slots": {platform: slot.to_dict() for platform, slot in self._platform_slots.items()},
576
+ "connection_count": len(self._connections),
577
+ "slot_count": len(self._platform_slots),
578
+ "heartbeat_timeout": self._heartbeat_timeout,
579
+ }