fix: Resolve multiple weather satellite decoder bugs

- Fix SatDump crash reported as "Capture complete" by collecting exit
  status via process.wait() before checking returncode
- Fix PTY file descriptor double-close race between stop() and reader
  thread by adding thread-safe _close_pty() helper with dedicated lock
- Fix image watcher missing final images by doing post-exit scans after
  SatDump process ends, using threading.Event for fast wakeup
- Fix failed image copy permanently skipping file by only marking as
  known after successful copy
- Fix frontend error handler not resetting isRunning, preventing new
  captures after a crash
- Fix console auto-hide timer leak on rapid complete/error events
- Fix ground track and auto-scheduler ignoring shared ObserverLocation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-17 16:16:28 +00:00
parent 34ecec3800
commit 23f28a8102
2 changed files with 117 additions and 68 deletions

View File

@@ -399,16 +399,20 @@ const WeatherSat = (function() {
addConsoleEntry('Capture complete', 'signal');
updatePhaseIndicator('complete');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
}
} else if (data.status === 'error') {
isRunning = false;
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Error');
showNotification('Weather Sat', data.message || 'Capture error');
if (captureStatus) captureStatus.classList.remove('active');
if (data.message) addConsoleEntry(data.message, 'error');
updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
}
}
@@ -761,8 +765,17 @@ const WeatherSat = (function() {
}).addTo(groundTrackLayer);
// Observer marker
const lat = parseFloat(localStorage.getItem('observerLat'));
const lon = parseFloat(localStorage.getItem('observerLon'));
let obsLat, obsLon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
obsLat = shared?.lat;
obsLon = shared?.lon;
} else {
obsLat = parseFloat(localStorage.getItem('observerLat'));
obsLon = parseFloat(localStorage.getItem('observerLon'));
}
const lat = obsLat;
const lon = obsLon;
if (!isNaN(lat) && !isNaN(lon)) {
L.circleMarker([lat, lon], {
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
@@ -946,8 +959,15 @@ const WeatherSat = (function() {
* Enable auto-scheduler
*/
async function enableScheduler() {
const lat = parseFloat(localStorage.getItem('observerLat'));
const lon = parseFloat(localStorage.getItem('observerLon'));
let lat, lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = shared?.lat;
lon = shared?.lon;
} else {
lat = parseFloat(localStorage.getItem('observerLat'));
lon = parseFloat(localStorage.getItem('observerLon'));
}
if (isNaN(lat) || isNaN(lon)) {
showNotification('Weather Sat', 'Set observer location first');

View File

@@ -156,7 +156,9 @@ class WeatherSatDecoder:
self._process: subprocess.Popen | None = None
self._running = False
self._lock = threading.Lock()
self._pty_lock = threading.Lock()
self._images_lock = threading.Lock()
self._stop_event = threading.Event()
self._callback: Callable[[CaptureProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
self._images: list[WeatherSatImage] = []
@@ -212,6 +214,16 @@ class WeatherSatDecoder:
)
return None
def _close_pty(self) -> None:
"""Close the PTY master fd in a thread-safe manner."""
with self._pty_lock:
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
"""Set callback for capture progress updates."""
self._callback = callback
@@ -292,6 +304,7 @@ class WeatherSatDecoder:
self._current_mode = sat_info['mode']
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
self._stop_event.clear()
try:
self._running = True
@@ -372,6 +385,7 @@ class WeatherSatDecoder:
self._device_index = device_index
self._capture_start_time = time.time()
self._capture_phase = 'tuning'
self._stop_event.clear()
try:
self._running = True
@@ -781,13 +795,11 @@ class WeatherSatDecoder:
except Exception as e:
logger.error(f"Error reading SatDump output: {e}")
finally:
# Close PTY master fd
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
# Close PTY master fd (thread-safe)
self._close_pty()
# Signal watcher thread to do final scan and exit
self._stop_event.set()
# Process ended — release resources
was_running = self._running
@@ -795,7 +807,13 @@ class WeatherSatDecoder:
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
if was_running:
# Check if SatDump exited with an error
# Collect exit status (returncode is only set after poll/wait)
if self._process and self._process.returncode is None:
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait()
retcode = self._process.returncode if self._process else None
if retcode and retcode != 0:
self._capture_phase = 'error'
@@ -837,63 +855,79 @@ class WeatherSatDecoder:
known_files: set[str] = set()
while self._running:
time.sleep(2)
self._scan_output_dir(known_files)
# Use stop_event for faster wakeup on process exit
if self._stop_event.wait(timeout=2):
break
try:
# Recursively scan for image files
for ext in ('*.png', '*.jpg', '*.jpeg'):
for filepath in self._capture_output_dir.rglob(ext):
file_key = str(filepath)
if file_key in known_files:
# Final scan — SatDump writes images at the end of processing,
# often after the process has already exited. Do multiple scans
# with a short delay to catch late-written files.
for _ in range(3):
time.sleep(0.5)
self._scan_output_dir(known_files)
def _scan_output_dir(self, known_files: set[str]) -> None:
"""Scan capture output directory for new image files."""
if not self._capture_output_dir:
return
try:
# Recursively scan for image files
for ext in ('*.png', '*.jpg', '*.jpeg'):
for filepath in self._capture_output_dir.rglob(ext):
file_key = str(filepath)
if file_key in known_files:
continue
# Skip tiny files (likely incomplete)
try:
stat = filepath.stat()
if stat.st_size < 1000:
continue
except OSError:
continue
# Skip tiny files (likely incomplete)
try:
stat = filepath.stat()
if stat.st_size < 1000:
continue
except OSError:
continue
# Determine product type from filename/path
product = self._parse_product_name(filepath)
known_files.add(file_key)
# Copy image to main output dir for serving
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
serve_path = self._output_dir / serve_name
try:
shutil.copy2(filepath, serve_path)
except OSError:
# Copy failed — don't mark as known so it can be retried
continue
# Determine product type from filename/path
product = self._parse_product_name(filepath)
# Only mark as known after successful copy
known_files.add(file_key)
# Copy image to main output dir for serving
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
serve_path = self._output_dir / serve_name
try:
shutil.copy2(filepath, serve_path)
except OSError:
serve_path = filepath
serve_name = filepath.name
image = WeatherSatImage(
filename=serve_name,
path=serve_path,
satellite=self._current_satellite,
mode=self._current_mode,
timestamp=datetime.now(timezone.utc),
frequency=self._current_frequency,
size_bytes=stat.st_size,
product=product,
)
with self._images_lock:
self._images.append(image)
image = WeatherSatImage(
filename=serve_name,
path=serve_path,
satellite=self._current_satellite,
mode=self._current_mode,
timestamp=datetime.now(timezone.utc),
frequency=self._current_frequency,
size_bytes=stat.st_size,
product=product,
)
with self._images_lock:
self._images.append(image)
logger.info(f"New weather satellite image: {serve_name} ({product})")
self._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f'Image decoded: {product}',
image=image,
))
logger.info(f"New weather satellite image: {serve_name} ({product})")
self._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f'Image decoded: {product}',
image=image,
))
except Exception as e:
logger.error(f"Error watching images: {e}")
except Exception as e:
logger.error(f"Error scanning for images: {e}")
def _parse_product_name(self, filepath: Path) -> str:
"""Parse a human-readable product name from the image filepath."""
@@ -931,13 +965,8 @@ class WeatherSatDecoder:
"""Stop weather satellite capture."""
with self._lock:
self._running = False
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
self._stop_event.set()
self._close_pty()
if self._process:
safe_terminate(self._process)