diff --git a/src/display_manager.py b/src/display_manager.py index bcf2bfd..d960249 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -1811,6 +1811,55 @@ class DisplayManager: # Screenshot capture subsystem # ------------------------------------------------------------- + def _pending_trigger_is_valid(self, meta: Dict) -> bool: + """Return True only for fresh, actionable pending trigger metadata. + + This prevents a stale/corrupt pending flag from permanently blocking + periodic updates (meta.json/latest.jpg) if simclient was down or test + data left send_immediately=True behind. + """ + try: + if not meta.get('send_immediately'): + return False + mtype = str(meta.get('type') or '') + if mtype not in ('event_start', 'event_stop'): + return False + mfile = str(meta.get('file') or '').strip() + if not mfile: + return False + file_path = os.path.join(self.screenshot_dir, mfile) + if not os.path.exists(file_path): + logging.warning( + f"Ignoring stale pending screenshot meta: missing file '{mfile}'" + ) + return False + + captured_at_raw = meta.get('captured_at') + if not captured_at_raw: + return False + captured_at = datetime.fromisoformat(str(captured_at_raw).replace('Z', '+00:00')) + age_s = (datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)).total_seconds() + + # Guard against malformed/future timestamps that could lock + # the pipeline by appearing permanently "fresh". + if age_s < -5: + logging.warning( + f"Ignoring invalid pending screenshot meta: future captured_at (age={age_s:.1f}s)" + ) + return False + + # Triggered screenshots should be consumed quickly (<= 1s). Use a + # generous safety window to avoid false negatives under load. + if age_s > 30: + logging.warning( + f"Ignoring stale pending screenshot meta: type={mtype}, age={age_s:.1f}s" + ) + return False + + return True + except Exception: + return False + def _write_screenshot_meta(self, capture_type: str, final_path: str, send_immediately: bool = False): """Write screenshots/meta.json atomically so simclient can detect new captures. @@ -1824,55 +1873,6 @@ class DisplayManager: send_immediately: True for triggered (event) captures, False for periodic ones """ try: - def _pending_trigger_is_valid(meta: Dict) -> bool: - """Return True only for fresh, actionable pending trigger metadata. - - This prevents a stale/corrupt pending flag from permanently blocking - periodic updates (meta.json/latest.jpg) if simclient was down or test - data left send_immediately=True behind. - """ - try: - if not meta.get('send_immediately'): - return False - mtype = str(meta.get('type') or '') - if mtype not in ('event_start', 'event_stop'): - return False - mfile = str(meta.get('file') or '').strip() - if not mfile: - return False - file_path = os.path.join(self.screenshot_dir, mfile) - if not os.path.exists(file_path): - logging.warning( - f"Ignoring stale pending screenshot meta: missing file '{mfile}'" - ) - return False - - captured_at_raw = meta.get('captured_at') - if not captured_at_raw: - return False - captured_at = datetime.fromisoformat(str(captured_at_raw).replace('Z', '+00:00')) - age_s = (datetime.now(timezone.utc) - captured_at.astimezone(timezone.utc)).total_seconds() - - # Guard against malformed/future timestamps that could lock - # the pipeline by appearing permanently "fresh". - if age_s < -5: - logging.warning( - f"Ignoring invalid pending screenshot meta: future captured_at (age={age_s:.1f}s)" - ) - return False - - # Triggered screenshots should be consumed quickly (<= 1s). Use a - # generous safety window to avoid false negatives under load. - if age_s > 30: - logging.warning( - f"Ignoring stale pending screenshot meta: type={mtype}, age={age_s:.1f}s" - ) - return False - - return True - except Exception: - return False - meta_path = os.path.join(self.screenshot_dir, 'meta.json') # PROTECTION: Don't overwrite pending event-triggered metadata with periodic capture @@ -1882,7 +1882,7 @@ class DisplayManager: with open(meta_path, 'r', encoding='utf-8') as f: existing_meta = json.load(f) # If there's a pending event-triggered capture, skip this periodic write - if _pending_trigger_is_valid(existing_meta): + if self._pending_trigger_is_valid(existing_meta): logging.debug(f"Skipping periodic meta.json to preserve pending {existing_meta.get('type')} (send_immediately=True)") return except Exception: @@ -2096,29 +2096,29 @@ class DisplayManager: # Maintain latest.jpg as an atomic copy so readers never see a missing # or broken pointer while a new screenshot is being published. - # PROTECTION: Don't update latest.jpg for periodic captures if event-triggered is pending - should_update_latest = True - if capture_type == "periodic": - try: - meta_path = os.path.join(self.screenshot_dir, 'meta.json') - if os.path.exists(meta_path): - with open(meta_path, 'r', encoding='utf-8') as f: - existing_meta = json.load(f) - # If there's a pending event-triggered capture, don't update latest.jpg - if _pending_trigger_is_valid(existing_meta): - should_update_latest = False - logging.debug(f"Skipping latest.jpg update to preserve pending {existing_meta.get('type')} screenshot") - except Exception: - pass # If we can't read meta, proceed with updating latest.jpg - - latest_link = os.path.join(self.screenshot_dir, 'latest.jpg') - if should_update_latest: - try: - latest_tmp = os.path.join(self.screenshot_dir, 'latest.jpg.tmp') - shutil.copyfile(final_path, latest_tmp) - os.replace(latest_tmp, latest_link) - except Exception as e: - logging.debug(f"Could not update latest.jpg: {e}") + # PROTECTION: Don't update latest.jpg for periodic captures if event-triggered is pending + should_update_latest = True + if capture_type == "periodic": + try: + meta_path = os.path.join(self.screenshot_dir, 'meta.json') + if os.path.exists(meta_path): + with open(meta_path, 'r', encoding='utf-8') as f: + existing_meta = json.load(f) + # If there's a pending event-triggered capture, don't update latest.jpg + if self._pending_trigger_is_valid(existing_meta): + should_update_latest = False + logging.debug(f"Skipping latest.jpg update to preserve pending {existing_meta.get('type')} screenshot") + except Exception: + pass # If we can't read meta, proceed with updating latest.jpg + + latest_link = os.path.join(self.screenshot_dir, 'latest.jpg') + if should_update_latest: + try: + latest_tmp = os.path.join(self.screenshot_dir, 'latest.jpg.tmp') + shutil.copyfile(final_path, latest_tmp) + os.replace(latest_tmp, latest_link) + except Exception as e: + logging.debug(f"Could not update latest.jpg: {e}") # Rotate old screenshots try: