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
@@ -3,6 +3,12 @@ Docker utilities for QEMU deployment.
3
3
 
4
4
  This module provides utilities for managing Docker containers for ESP32 QEMU emulation,
5
5
  including automatic Docker daemon startup detection and image management.
6
+
7
+ Enhanced Docker auto-start functionality based on patterns from fastled-wasm:
8
+ - WSL2 backend detection on Windows
9
+ - Docker Desktop restart workflow
10
+ - Retry logic for Docker client connection
11
+ - Cross-platform support (Windows, macOS, Linux)
6
12
  """
7
13
 
8
14
  import os
@@ -10,6 +16,7 @@ import platform
10
16
  import subprocess
11
17
  import sys
12
18
  import time
19
+ from pathlib import Path
13
20
  from typing import Optional
14
21
 
15
22
  from fbuild.interrupt_utils import handle_keyboard_interrupt_properly
@@ -73,11 +80,14 @@ def get_docker_desktop_path() -> Optional[str]:
73
80
 
74
81
  if system == "Windows":
75
82
  # Common locations for Docker Desktop on Windows
83
+ home_dir = Path.home()
76
84
  paths = [
77
85
  r"C:\Program Files\Docker\Docker\Docker Desktop.exe",
78
86
  r"C:\Program Files (x86)\Docker\Docker\Docker Desktop.exe",
79
87
  os.path.expandvars(r"%ProgramFiles%\Docker\Docker\Docker Desktop.exe"),
80
88
  os.path.expandvars(r"%LocalAppData%\Programs\Docker\Docker\Docker Desktop.exe"),
89
+ str(home_dir / "AppData" / "Local" / "Docker" / "Docker Desktop.exe"),
90
+ str(home_dir / "AppData" / "Local" / "Programs" / "Docker" / "Docker" / "Docker Desktop.exe"),
81
91
  ]
82
92
  for path in paths:
83
93
  if os.path.exists(path):
@@ -99,11 +109,149 @@ def get_docker_desktop_path() -> Optional[str]:
99
109
  return None
100
110
 
101
111
 
112
+ def _check_wsl2_docker_backend() -> tuple[bool, str]:
113
+ """Check if Docker's WSL2 backend (docker-desktop) is running on Windows.
114
+
115
+ Returns:
116
+ tuple[bool, str]: (is_running, status_message)
117
+ """
118
+ if platform.system() != "Windows":
119
+ return True, "Not Windows - WSL2 check not applicable"
120
+
121
+ try:
122
+ result = subprocess.run(
123
+ ["wsl", "--list", "--verbose"],
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=10,
127
+ env=get_docker_env(),
128
+ )
129
+
130
+ if result.returncode != 0:
131
+ return False, "WSL2 not installed (wsl command failed)"
132
+
133
+ output = result.stdout
134
+
135
+ # Handle WSL output encoding issues (Git Bash adds spaces between characters)
136
+ cleaned_lines = []
137
+ for line in output.split("\n"):
138
+ cleaned = line.replace("\x00", "").strip()
139
+ # Remove spaces between single characters: "d o c k e r" -> "docker"
140
+ if " " in cleaned and len([c for c in cleaned.split() if len(c) == 1]) > 3:
141
+ cleaned = cleaned.replace(" ", "")
142
+ cleaned_lines.append(cleaned)
143
+
144
+ # Detect status: running/stopped
145
+ for line in cleaned_lines:
146
+ if "docker-desktop" in line.lower():
147
+ if "running" in line.lower():
148
+ return True, "docker-desktop WSL2 backend is running"
149
+ elif "stopped" in line.lower():
150
+ return False, "docker-desktop WSL2 backend is stopped"
151
+
152
+ return False, "docker-desktop WSL2 distribution not found"
153
+
154
+ except FileNotFoundError:
155
+ return False, "WSL2 not installed (wsl command not found)"
156
+ except subprocess.TimeoutExpired:
157
+ return False, "WSL2 command timed out"
158
+ except KeyboardInterrupt as ke:
159
+ handle_keyboard_interrupt_properly(ke)
160
+ return False, "Interrupted by user" # Will not reach here
161
+ except Exception as e:
162
+ return False, f"Error checking WSL2 backend: {e}"
163
+
164
+
165
+ def _kill_docker_desktop_windows() -> bool:
166
+ """Forcefully terminate Docker Desktop and backend processes on Windows.
167
+
168
+ Returns:
169
+ bool: True if processes were killed successfully
170
+ """
171
+ if platform.system() != "Windows":
172
+ return False
173
+
174
+ try:
175
+ # Kill Docker Desktop GUI
176
+ subprocess.run(
177
+ ["taskkill", "/F", "/IM", "Docker Desktop.exe"],
178
+ capture_output=True,
179
+ timeout=10,
180
+ )
181
+
182
+ # Kill Docker backend engine
183
+ subprocess.run(
184
+ ["taskkill", "/F", "/IM", "com.docker.backend.exe"],
185
+ capture_output=True,
186
+ timeout=10,
187
+ )
188
+
189
+ time.sleep(3) # Allow cleanup
190
+ return True
191
+ except KeyboardInterrupt as ke:
192
+ handle_keyboard_interrupt_properly(ke)
193
+ return False # Will not reach here
194
+ except Exception:
195
+ return False
196
+
197
+
198
+ def _restart_docker_desktop_windows() -> tuple[bool, str]:
199
+ """Complete Docker Desktop restart workflow on Windows.
200
+
201
+ Returns:
202
+ tuple[bool, str]: (success, message)
203
+ """
204
+ print(" Attempting to restart Docker Desktop to fix WSL2 backend...")
205
+
206
+ docker_path = get_docker_desktop_path()
207
+ if not docker_path:
208
+ return False, "Docker Desktop executable not found"
209
+
210
+ print(" Stopping Docker Desktop...")
211
+ _kill_docker_desktop_windows()
212
+
213
+ time.sleep(5) # Wait for cleanup
214
+
215
+ print(" Starting Docker Desktop...")
216
+ try:
217
+ subprocess.Popen(
218
+ [docker_path],
219
+ stdout=subprocess.DEVNULL,
220
+ stderr=subprocess.DEVNULL,
221
+ creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
222
+ )
223
+ except KeyboardInterrupt as ke:
224
+ handle_keyboard_interrupt_properly(ke)
225
+ return False, "Interrupted by user" # Will not reach here
226
+ except Exception as e:
227
+ return False, f"Failed to start Docker Desktop: {e}"
228
+
229
+ print(" Waiting for Docker Desktop and WSL2 backend to initialize...")
230
+
231
+ # Poll for up to 2 minutes
232
+ for attempt in range(120):
233
+ time.sleep(1)
234
+
235
+ # Check if Docker engine is available
236
+ if check_docker_daemon_running():
237
+ # Also check WSL2 backend
238
+ wsl_running, _ = _check_wsl2_docker_backend()
239
+ if wsl_running:
240
+ return True, "Docker Desktop restarted successfully - WSL2 backend is running"
241
+
242
+ # Progress indicator every 15 seconds
243
+ if (attempt + 1) % 15 == 0:
244
+ print(f" Still waiting ({attempt + 1}s)...")
245
+
246
+ return False, "Docker Desktop started but WSL2 backend failed to initialize within 2 minutes"
247
+
248
+
102
249
  def start_docker_daemon() -> bool:
103
250
  """Attempt to start the Docker daemon.
104
251
 
105
252
  This function tries to start Docker Desktop on Windows/macOS
106
- or the Docker service on Linux.
253
+ or the Docker service on Linux. On Windows, it includes WSL2
254
+ backend detection and restart workflow.
107
255
 
108
256
  Returns:
109
257
  True if Docker daemon started successfully, False otherwise
@@ -116,6 +264,22 @@ def start_docker_daemon() -> bool:
116
264
  print("Docker daemon is not running. Attempting to start...")
117
265
 
118
266
  if system == "Windows":
267
+ # Check WSL2 backend status on Windows
268
+ print(" Checking Docker Desktop WSL2 backend status...")
269
+ wsl_running, wsl_message = _check_wsl2_docker_backend()
270
+ print(f" WSL2 Status: {wsl_message}")
271
+
272
+ # Detect split-brain state and trigger restart
273
+ if not wsl_running and "stopped" in wsl_message.lower():
274
+ print(" Issue detected: Docker Desktop app may be running but WSL2 backend is stopped")
275
+ success, message = _restart_docker_desktop_windows()
276
+ if success:
277
+ print(f" {message}")
278
+ return True
279
+ else:
280
+ print(f" {message}")
281
+ return False
282
+
119
283
  docker_path = get_docker_desktop_path()
120
284
  if docker_path:
121
285
  try:
@@ -126,9 +290,22 @@ def start_docker_daemon() -> bool:
126
290
  stderr=subprocess.DEVNULL,
127
291
  creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
128
292
  )
129
- return _wait_for_docker_daemon()
293
+ success = _wait_for_docker_daemon(timeout=120) # 2 minute timeout
294
+
295
+ if not success:
296
+ # If startup failed, try full restart
297
+ print(" Docker Desktop didn't start properly, attempting full restart...")
298
+ success, message = _restart_docker_desktop_windows()
299
+ if success:
300
+ print(f" {message}")
301
+ return True
302
+ else:
303
+ print(f" {message}")
304
+ return False
305
+ return success
130
306
  except KeyboardInterrupt as ke:
131
307
  handle_keyboard_interrupt_properly(ke)
308
+ return False
132
309
  except Exception as e:
133
310
  print(f"Failed to start Docker Desktop: {e}")
134
311
  return False
@@ -148,6 +325,7 @@ def start_docker_daemon() -> bool:
148
325
  return _wait_for_docker_daemon()
149
326
  except KeyboardInterrupt as ke:
150
327
  handle_keyboard_interrupt_properly(ke)
328
+ return False
151
329
  except Exception as e:
152
330
  print(f"Failed to start Docker Desktop: {e}")
153
331
  return False
@@ -176,6 +354,7 @@ def start_docker_daemon() -> bool:
176
354
  return _wait_for_docker_daemon()
177
355
  except KeyboardInterrupt as ke:
178
356
  handle_keyboard_interrupt_properly(ke)
357
+ return False
179
358
  except Exception as e:
180
359
  print(f"Failed to start Docker service: {e}")
181
360
 
@@ -278,6 +457,7 @@ def pull_docker_image(image_name: str, timeout: int = 600) -> bool:
278
457
  return False
279
458
  except KeyboardInterrupt as ke:
280
459
  handle_keyboard_interrupt_properly(ke)
460
+ return False # Will not reach here, but satisfies type checker
281
461
  except subprocess.TimeoutExpired:
282
462
  print(f"Timeout pulling {image_name}")
283
463
  return False
fbuild/deploy/monitor.py CHANGED
@@ -450,7 +450,7 @@ class SerialMonitor:
450
450
 
451
451
  return 1
452
452
  except KeyboardInterrupt:
453
- # Interrupt other threads
453
+ # Interrupt other threads (notify them of the interrupt)
454
454
  _thread.interrupt_main()
455
455
 
456
456
  elapsed_time = time.time() - start_time if "start_time" in locals() else 0.0
@@ -189,12 +189,26 @@ class QEMURunner:
189
189
  print(f"Error pulling Docker image: {e}")
190
190
  return False
191
191
 
192
- def _prepare_firmware(self, firmware_path: Path, flash_size_mb: int = 4) -> Path:
192
+ def _prepare_firmware(self, firmware_path: Path, flash_size_mb: int = 4, machine: str = "esp32") -> Path:
193
193
  """Prepare firmware files for mounting into Docker container.
194
194
 
195
+ Creates a complete flash image with bootloader, partition table,
196
+ and application at their correct offsets for QEMU.
197
+
198
+ ESP32/ESP32-S2 Flash Layout:
199
+ - 0x1000: Bootloader (second stage)
200
+ - 0x8000: Partition table
201
+ - 0x10000: Application (firmware.bin)
202
+
203
+ ESP32-S3/ESP32-C3/ESP32-C6 Flash Layout:
204
+ - 0x0000: Bootloader (second stage)
205
+ - 0x8000: Partition table
206
+ - 0x10000: Application (firmware.bin)
207
+
195
208
  Args:
196
209
  firmware_path: Path to firmware.bin file
197
210
  flash_size_mb: Flash size in MB (must be 2, 4, 8, or 16)
211
+ machine: QEMU machine type (esp32, esp32s3, esp32c3)
198
212
 
199
213
  Returns:
200
214
  Path to the prepared firmware directory
@@ -212,19 +226,63 @@ class QEMURunner:
212
226
  # Create proper flash image for QEMU
213
227
  flash_size = flash_size_mb * 1024 * 1024
214
228
 
215
- # Read firmware content
216
- firmware_data = firmware_path.read_bytes()
217
-
218
- # Create flash image: firmware at beginning, rest filled with 0xFF
219
- flash_data = firmware_data + b"\xff" * (flash_size - len(firmware_data))
220
-
221
- # Ensure we have exactly the right size
222
- if len(flash_data) > flash_size:
223
- raise ValueError(f"Firmware size ({len(firmware_data)} bytes) exceeds flash size ({flash_size} bytes)")
229
+ # ESP32 flash layout offsets - different MCUs have different bootloader offsets
230
+ # ESP32/ESP32-S2: 0x1000, ESP32-S3/C3/C6: 0x0
231
+ if machine in ["esp32", "esp32s2"]:
232
+ BOOTLOADER_OFFSET = 0x1000
233
+ else:
234
+ BOOTLOADER_OFFSET = 0x0
235
+ PARTITION_OFFSET = 0x8000
236
+ APP_OFFSET = 0x10000
237
+
238
+ # Start with erased flash (0xFF)
239
+ flash_data = bytearray(b"\xff" * flash_size)
240
+
241
+ # Try to find bootloader.bin and partitions.bin in the same directory
242
+ build_dir = firmware_path.parent
243
+ bootloader_path = build_dir / "bootloader.bin"
244
+ partitions_path = build_dir / "partitions.bin"
245
+
246
+ # Place bootloader at 0x1000 if available
247
+ if bootloader_path.exists():
248
+ bootloader_data = bootloader_path.read_bytes()
249
+ bootloader_end = BOOTLOADER_OFFSET + len(bootloader_data)
250
+ if bootloader_end <= flash_size:
251
+ flash_data[BOOTLOADER_OFFSET:bootloader_end] = bootloader_data
252
+ if self.verbose:
253
+ print(f" Bootloader: {len(bootloader_data)} bytes at 0x{BOOTLOADER_OFFSET:X}")
254
+ else:
255
+ print("Warning: Bootloader too large to fit in flash")
256
+ else:
257
+ if self.verbose:
258
+ print(f" Warning: bootloader.bin not found at {bootloader_path}")
259
+
260
+ # Place partition table at 0x8000 if available
261
+ if partitions_path.exists():
262
+ partitions_data = partitions_path.read_bytes()
263
+ partitions_end = PARTITION_OFFSET + len(partitions_data)
264
+ if partitions_end <= flash_size:
265
+ flash_data[PARTITION_OFFSET:partitions_end] = partitions_data
266
+ if self.verbose:
267
+ print(f" Partitions: {len(partitions_data)} bytes at 0x{PARTITION_OFFSET:X}")
268
+ else:
269
+ print("Warning: Partition table too large to fit in flash")
270
+ else:
271
+ if self.verbose:
272
+ print(f" Warning: partitions.bin not found at {partitions_path}")
224
273
 
225
- flash_data = flash_data[:flash_size] # Truncate to exact size
274
+ # Place firmware at 0x10000
275
+ firmware_data = firmware_path.read_bytes()
276
+ firmware_end = APP_OFFSET + len(firmware_data)
277
+ if firmware_end > flash_size:
278
+ raise ValueError(f"Firmware size ({len(firmware_data)} bytes) exceeds available space (flash_size={flash_size} - app_offset={APP_OFFSET})")
279
+ flash_data[APP_OFFSET:firmware_end] = firmware_data
280
+ if self.verbose:
281
+ print(f" Application: {len(firmware_data)} bytes at 0x{APP_OFFSET:X}")
226
282
 
227
- (temp_dir / "flash.bin").write_bytes(flash_data)
283
+ # Write the complete flash image
284
+ (temp_dir / "flash.bin").write_bytes(bytes(flash_data))
285
+ print(f"Created flash image: {flash_size_mb}MB ({flash_size:,} bytes)")
228
286
 
229
287
  return temp_dir
230
288
 
@@ -352,7 +410,7 @@ class QEMURunner:
352
410
  temp_firmware_dir: Optional[Path] = None
353
411
 
354
412
  try:
355
- temp_firmware_dir = self._prepare_firmware(firmware_path, flash_size)
413
+ temp_firmware_dir = self._prepare_firmware(firmware_path, flash_size, machine)
356
414
 
357
415
  # Generate unique container name
358
416
  self.container_name = f"fbuild-qemu-{machine}-{int(time.time())}"
@@ -2,15 +2,16 @@
2
2
  Board Ledger - Track attached chip/port mappings.
3
3
 
4
4
  This module provides a simple ledger to cache chip type detections for serial ports.
5
- The cache is stored in ~/.fbuild/board_ledger.json and uses file locking for
6
- thread-safe access.
5
+ The cache is stored in ~/.fbuild/board_ledger.json.
7
6
 
8
7
  Features:
9
8
  - Port to chip type mapping with timestamps
10
9
  - Automatic stale entry expiration (24 hours)
11
- - Thread-safe file access with file locking
10
+ - Thread-safe in-process access via threading.Lock
12
11
  - Chip type validation against known ESP32 variants
13
12
  - Integration with esptool for chip detection
13
+
14
+ Note: Cross-process synchronization is handled by the daemon which holds locks in memory.
14
15
  """
15
16
 
16
17
  import json
@@ -96,7 +97,8 @@ class BoardLedger:
96
97
  """Manages port to chip type mappings with persistent storage.
97
98
 
98
99
  The ledger stores mappings in ~/.fbuild/board_ledger.json and provides
99
- thread-safe access through file locking.
100
+ thread-safe in-process access through threading.Lock. Cross-process
101
+ synchronization is handled by the daemon which holds locks in memory.
100
102
 
101
103
  Example:
102
104
  >>> ledger = BoardLedger()
@@ -161,60 +163,6 @@ class BoardLedger:
161
163
  except OSError as e:
162
164
  raise BoardLedgerError(f"Failed to write ledger: {e}") from e
163
165
 
164
- def _acquire_file_lock(self) -> Any:
165
- """Acquire a file lock for cross-process synchronization.
166
-
167
- Returns:
168
- Lock file handle (or None on platforms without locking support)
169
- """
170
- self._ensure_directory()
171
- lock_path = self._ledger_path.with_suffix(".lock")
172
-
173
- try:
174
- # Open lock file
175
- lock_file = open(lock_path, "w", encoding="utf-8")
176
-
177
- # Platform-specific locking
178
- if sys.platform == "win32":
179
- import msvcrt
180
-
181
- msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
182
- else: # pragma: no cover - Unix only
183
- import fcntl # type: ignore[import-not-found]
184
-
185
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
186
-
187
- return lock_file
188
- except (ImportError, OSError):
189
- # Locking not available or failed - continue without lock
190
- return None
191
-
192
- def _release_file_lock(self, lock_file: Any) -> None:
193
- """Release a file lock.
194
-
195
- Args:
196
- lock_file: Lock file handle from _acquire_file_lock
197
- """
198
- if lock_file is None:
199
- return
200
-
201
- try:
202
- if sys.platform == "win32":
203
- import msvcrt
204
-
205
- try:
206
- msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
207
- except OSError:
208
- pass
209
- else: # pragma: no cover - Unix only
210
- import fcntl # type: ignore[import-not-found]
211
-
212
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
213
-
214
- lock_file.close()
215
- except (ImportError, OSError):
216
- pass
217
-
218
166
  def get_chip(self, port: str) -> str | None:
219
167
  """Get the cached chip type for a port.
220
168
 
@@ -225,20 +173,16 @@ class BoardLedger:
225
173
  Chip type string (e.g., "ESP32-S3") or None if not found/stale
226
174
  """
227
175
  with self._lock:
228
- lock_file = self._acquire_file_lock()
229
- try:
230
- data = self._read_ledger()
231
- entry_data = data.get(port)
232
- if entry_data is None:
233
- return None
176
+ data = self._read_ledger()
177
+ entry_data = data.get(port)
178
+ if entry_data is None:
179
+ return None
234
180
 
235
- entry = LedgerEntry.from_dict(entry_data)
236
- if entry.is_stale():
237
- return None
181
+ entry = LedgerEntry.from_dict(entry_data)
182
+ if entry.is_stale():
183
+ return None
238
184
 
239
- return entry.chip_type
240
- finally:
241
- self._release_file_lock(lock_file)
185
+ return entry.chip_type
242
186
 
243
187
  def set_chip(self, port: str, chip_type: str) -> None:
244
188
  """Set the chip type for a port.
@@ -258,13 +202,9 @@ class BoardLedger:
258
202
  entry = LedgerEntry(chip_type=normalized, timestamp=time.time())
259
203
 
260
204
  with self._lock:
261
- lock_file = self._acquire_file_lock()
262
- try:
263
- data = self._read_ledger()
264
- data[port] = entry.to_dict()
265
- self._write_ledger(data)
266
- finally:
267
- self._release_file_lock(lock_file)
205
+ data = self._read_ledger()
206
+ data[port] = entry.to_dict()
207
+ self._write_ledger(data)
268
208
 
269
209
  def clear(self, port: str) -> bool:
270
210
  """Clear the cached chip type for a port.
@@ -276,16 +216,12 @@ class BoardLedger:
276
216
  True if entry was cleared, False if not found
277
217
  """
278
218
  with self._lock:
279
- lock_file = self._acquire_file_lock()
280
- try:
281
- data = self._read_ledger()
282
- if port in data:
283
- del data[port]
284
- self._write_ledger(data)
285
- return True
286
- return False
287
- finally:
288
- self._release_file_lock(lock_file)
219
+ data = self._read_ledger()
220
+ if port in data:
221
+ del data[port]
222
+ self._write_ledger(data)
223
+ return True
224
+ return False
289
225
 
290
226
  def clear_all(self) -> int:
291
227
  """Clear all entries from the ledger.
@@ -294,14 +230,10 @@ class BoardLedger:
294
230
  Number of entries cleared
295
231
  """
296
232
  with self._lock:
297
- lock_file = self._acquire_file_lock()
298
- try:
299
- data = self._read_ledger()
300
- count = len(data)
301
- self._write_ledger({})
302
- return count
303
- finally:
304
- self._release_file_lock(lock_file)
233
+ data = self._read_ledger()
234
+ count = len(data)
235
+ self._write_ledger({})
236
+ return count
305
237
 
306
238
  def clear_stale(self, threshold: float = STALE_THRESHOLD_SECONDS) -> int:
307
239
  """Remove all stale entries from the ledger.
@@ -313,22 +245,18 @@ class BoardLedger:
313
245
  Number of entries removed
314
246
  """
315
247
  with self._lock:
316
- lock_file = self._acquire_file_lock()
317
- try:
318
- data = self._read_ledger()
319
- original_count = len(data)
320
-
321
- # Filter out stale entries
322
- fresh_data = {}
323
- for port, entry_data in data.items():
324
- entry = LedgerEntry.from_dict(entry_data)
325
- if not entry.is_stale(threshold):
326
- fresh_data[port] = entry_data
327
-
328
- self._write_ledger(fresh_data)
329
- return original_count - len(fresh_data)
330
- finally:
331
- self._release_file_lock(lock_file)
248
+ data = self._read_ledger()
249
+ original_count = len(data)
250
+
251
+ # Filter out stale entries
252
+ fresh_data = {}
253
+ for port, entry_data in data.items():
254
+ entry = LedgerEntry.from_dict(entry_data)
255
+ if not entry.is_stale(threshold):
256
+ fresh_data[port] = entry_data
257
+
258
+ self._write_ledger(fresh_data)
259
+ return original_count - len(fresh_data)
332
260
 
333
261
  def get_all(self) -> dict[str, LedgerEntry]:
334
262
  """Get all non-stale entries in the ledger.
@@ -337,17 +265,13 @@ class BoardLedger:
337
265
  Dictionary mapping port names to LedgerEntry objects
338
266
  """
339
267
  with self._lock:
340
- lock_file = self._acquire_file_lock()
341
- try:
342
- data = self._read_ledger()
343
- result = {}
344
- for port, entry_data in data.items():
345
- entry = LedgerEntry.from_dict(entry_data)
346
- if not entry.is_stale():
347
- result[port] = entry
348
- return result
349
- finally:
350
- self._release_file_lock(lock_file)
268
+ data = self._read_ledger()
269
+ result = {}
270
+ for port, entry_data in data.items():
271
+ entry = LedgerEntry.from_dict(entry_data)
272
+ if not entry.is_stale():
273
+ result[port] = entry
274
+ return result
351
275
 
352
276
  def get_environment(self, port: str) -> str | None:
353
277
  """Get the environment name for a cached port.