Skip to content

Commit 62e0496

Browse files
committed
Skip unreadable files instead of hanging the scan (#70) (v2.7.1)
A file whose raw reads stall (dead NFS/SMB/fuse mount, failing disk sector) blocked a chunk worker in the kernel forever: os.stat, libmagic type detection, and the SHA-256 hash read ran with no deadline, before any of the timeout-guarded external tools. Progress froze with no ffmpeg/ImageMagick process visible, and because the file stayed 'pending', a container restart resumed straight back into the same hang. - stat/magic/hash now run under a watchdog-thread deadline (FILE_READ_TIMEOUT_SECS, default 60s; hash deadline scales with file size assuming a 5MB/s storage floor) - on timeout the file is marked corrupted with a stalled-read detail and persisted, so no scan path re-selects it - at 32 concurrently stalled reads, new reads fail fast so a dead mount degrades to per-file errors instead of fd exhaustion - scan_file error results are now persisted in the non-chunked paths too - FILE_READ_TIMEOUT_SECS and FFPROBE_TIMEOUT_SECS documented in docs/CONFIGURATION.md
1 parent 3751c19 commit 62e0496

5 files changed

Lines changed: 296 additions & 68 deletions

File tree

CHANGELOG.MD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0).
77

8+
## [2.7.1] - 2026-07-16
9+
10+
### Fixed
11+
12+
- **A single unreadable file no longer hangs an entire scan (#70).** External tools (ffprobe, ffmpeg, ImageMagick) already run under hard timeouts, but the pure-Python reads that precede them - os.stat, libmagic type detection, and the SHA-256 hash read - had none. A file on stalled storage (dead NFS/SMB/fuse mount, failing disk sector) blocked the chunk worker in the kernel forever with no ffmpeg or ImageMagick process visible, the progress counter and ETA froze, and because the file was still `pending`, restarting the containers resumed the scan straight back into the same hang. These reads now run under a watchdog deadline (`FILE_READ_TIMEOUT_SECS`, default 60s; the hash deadline additionally scales with file size at an assumed 5MB/s floor). On timeout the file is marked corrupted with a "stalled read" detail, the result is persisted so no scan path re-selects the file, and the scan moves on. A stalled read that times out abandons one watchdog thread until the kernel releases it; at 32 concurrently stalled reads new reads fail fast instead of piling up, so a fully dead mount degrades to fast per-file errors rather than fd exhaustion.
13+
814
## [2.7.0] - 2026-07-16
915

1016
### Breaking

docs/CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ All configuration is done via environment variables, either in `.env` file or di
6262
| `MAX_OUTPUT_SIZE` | `10000` | Max output characters before rotation | 10000-50000 |
6363
| `OUTPUT_ROTATION_ENABLED` | `true` | Enable output truncation | `true` for large scans |
6464
| `FREEZE_DETECTION_ENABLED` | `true` | Enable video freeze detection (freezedetect + blackdetect) | `false` to skip and reduce scan time |
65+
| `FILE_READ_TIMEOUT_SECS` | `60` | Deadline in seconds for raw file reads (stat, type detection, hash). A file whose reads stall past it (dead network mount, failing sector) is marked corrupted and skipped instead of hanging the scan. The hash deadline scales up with file size assuming at least 5MB/s storage throughput | Raise the base on storage slower than 5MB/s sustained |
66+
| `FFPROBE_TIMEOUT_SECS` | `120` | Hard ceiling in seconds for ffprobe metadata reads | Raise on very slow storage |
6567

6668
**Performance Notes:**
6769
- `MAX_WORKERS` controls parallelism within each scan task

pixelprobe/media_checker.py

Lines changed: 162 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import hashlib
77
import json
8+
import mmap
89
import time
910
from datetime import datetime, timezone
1011
from pathlib import Path
@@ -23,6 +24,7 @@
2324
from concurrent.futures import ThreadPoolExecutor, as_completed
2425
import threading
2526
from pixelprobe.utils.security import safe_subprocess_run, validate_file_path, ensure_cli_safe_path
27+
from pixelprobe.utils.helpers import env_int
2628
from pixelprobe.utils.integrity import apply_scan_baseline
2729
from pixelprobe.utils.paths import is_path_under
2830

@@ -55,6 +57,64 @@ def _ffprobe_with_timeout(file_path, timeout=None):
5557
raise ffmpeg.Error('ffprobe', result.stdout, result.stderr)
5658
return json.loads(result.stdout.decode('utf-8'))
5759

60+
# Deadline for pure-Python file reads (stat, magic, hash). Unlike the external
61+
# tools these have no subprocess timeout, so a file on stalled storage (dead
62+
# NFS/SMB mount, failing sector) blocks the scan worker in the kernel forever
63+
# with no ffmpeg/ImageMagick process visible (issue #70). Env-overridable.
64+
FILE_READ_TIMEOUT_SECS = env_int('FILE_READ_TIMEOUT_SECS', 60, floor=10)
65+
66+
# Each timed-out read abandons one stuck thread (and its fd) until the kernel
67+
# read returns. Cap how many may be live at once so a fully dead mount fails
68+
# fast instead of exhausting the fd table one file at a time.
69+
_MAX_ABANDONED_READ_THREADS = 32
70+
_abandoned_read_threads = []
71+
_abandoned_read_threads_lock = threading.Lock()
72+
73+
74+
class FileReadTimeoutError(Exception):
75+
"""A raw file read stalled past its deadline (dead mount / bad sector)."""
76+
77+
78+
def _read_with_timeout(func, timeout, file_path, operation):
79+
"""Run a blocking read in a watchdog thread with a hard deadline.
80+
81+
A read stuck in uninterruptible kernel sleep cannot be interrupted or
82+
killed; on timeout the daemon thread is abandoned (it holds one fd until
83+
the read returns or the process exits) and FileReadTimeoutError is raised
84+
so the scan marks the file unreadable and moves on instead of hanging the
85+
whole chunk. Once _MAX_ABANDONED_READ_THREADS reads are stuck, new reads
86+
fail immediately without spawning more threads.
87+
"""
88+
with _abandoned_read_threads_lock:
89+
_abandoned_read_threads[:] = [t for t in _abandoned_read_threads if t.is_alive()]
90+
if len(_abandoned_read_threads) >= _MAX_ABANDONED_READ_THREADS:
91+
raise FileReadTimeoutError(
92+
f'{operation} of {file_path} skipped - '
93+
f'{len(_abandoned_read_threads)} reads already stalled, '
94+
f'storage appears unreachable')
95+
96+
result = {}
97+
98+
def target():
99+
try:
100+
result['value'] = func()
101+
except Exception as e:
102+
result['error'] = e
103+
104+
thread = threading.Thread(target=target, daemon=True,
105+
name=f'read-watchdog:{operation}')
106+
thread.start()
107+
thread.join(timeout)
108+
if thread.is_alive():
109+
with _abandoned_read_threads_lock:
110+
_abandoned_read_threads.append(thread)
111+
raise FileReadTimeoutError(
112+
f'{operation} of {file_path} stalled for {timeout}s - '
113+
f'skipping unreadable file')
114+
if 'error' in result:
115+
raise result['error']
116+
return result.get('value')
117+
58118
# Pre-compiled patterns for parsing FFmpeg freezedetect filter output
59119
_RE_FREEZE_START = re.compile(r'freeze_start:\s*([\d.]+)')
60120
_RE_FREEZE_END = re.compile(r'freeze_end:\s*([\d.]+)')
@@ -511,25 +571,37 @@ def _is_supported_file(self, file_path):
511571

512572
return extension in self.supported_formats
513573

514-
def get_file_info(self, file_path):
515-
"""Get basic file information without scanning for corruption"""
574+
def get_file_info(self, file_path, timeout=None):
575+
"""Get basic file information without scanning for corruption
576+
577+
Raises FileReadTimeoutError if stat/magic stall (issue #70); other
578+
errors keep returning the fallback dict.
579+
"""
580+
if timeout is None:
581+
timeout = FILE_READ_TIMEOUT_SECS
516582
try:
517-
file_stats = os.stat(file_path)
583+
def read_info():
584+
stats = os.stat(file_path)
585+
return stats, magic.from_file(file_path, mime=True)
586+
587+
file_stats, file_type = _read_with_timeout(
588+
read_info, timeout, file_path, 'stat/magic')
518589
file_size = file_stats.st_size
519590
# UTC-aware: bitrot classification compares this stored baseline
520591
# against a UTC mtime, and naive local values poison it (the old
521592
# naive form is why mtime_baseline_utc exists).
522593
creation_date = datetime.fromtimestamp(file_stats.st_ctime, timezone.utc)
523594
last_modified = datetime.fromtimestamp(file_stats.st_mtime, timezone.utc)
524-
file_type = magic.from_file(file_path, mime=True)
525-
595+
526596
return {
527597
'file_path': file_path,
528598
'file_size': file_size,
529599
'file_type': file_type,
530600
'creation_date': creation_date,
531601
'last_modified': last_modified
532602
}
603+
except FileReadTimeoutError:
604+
raise
533605
except Exception as e:
534606
logger.error(f"Error getting file info for {file_path}: {str(e)}")
535607
return {
@@ -540,74 +612,90 @@ def get_file_info(self, file_path):
540612
'last_modified': datetime.now(timezone.utc)
541613
}
542614

543-
def calculate_file_hash(self, file_path):
544-
"""Calculate SHA-256 hash of a file with optimized chunk size"""
545-
try:
546-
logger.info(f"Calculating hash for: {file_path}")
547-
hash_sha256 = hashlib.sha256()
548-
start_time = time.time()
549-
bytes_processed = 0
550-
551-
# Get file size to determine optimal chunk size
552-
file_size = os.path.getsize(file_path)
553-
554-
# NEVER skip hash - integrity checking is critical for all files
555-
# Use mmap for files > 100MB (5-10x faster), adaptive buffering for smaller files
556-
import mmap
557-
558-
if file_size > 100 * 1024 * 1024: # 100MB threshold
559-
# Use memory-mapped I/O for large files (5-10x faster)
560-
logger.info(f"Hashing large file ({file_size/1024/1024/1024:.1f}GB) with mmap: {file_path}")
561-
try:
562-
with open(file_path, "rb") as f:
563-
# Map entire file into memory (OS handles paging)
564-
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
565-
hash_sha256.update(mm)
566-
bytes_processed = file_size
567-
except (OSError, ValueError) as e:
568-
# Fallback to buffered read if mmap fails
569-
logger.warning(f"mmap failed for {file_path}, falling back to buffered read: {e}")
570-
with open(file_path, "rb") as f:
571-
chunk_size = 16 * 1024 * 1024 # 16MB chunks
572-
while True:
573-
chunk = f.read(chunk_size)
574-
if not chunk:
575-
break
576-
hash_sha256.update(chunk)
577-
bytes_processed += len(chunk)
578-
else:
579-
# Use adaptive chunk sizes for smaller files
580-
chunk_size = 1024 * 1024 # 1MB chunks for files up to 1GB
615+
def calculate_file_hash(self, file_path, timeout=None, file_size=None):
616+
"""Calculate SHA-256 hash of a file with optimized chunk size
581617
582-
# For files 1-10GB, use larger chunks
583-
if file_size > 1024 * 1024 * 1024:
584-
chunk_size = 4 * 1024 * 1024 # 4MB chunks
618+
Raises FileReadTimeoutError if the read stalls past the deadline
619+
(issue #70); all other errors keep returning None. Pass file_size
620+
when already known to skip a redundant guarded stat.
621+
"""
622+
try:
623+
if file_size is None:
624+
file_size = _read_with_timeout(
625+
lambda: os.path.getsize(file_path),
626+
timeout if timeout is not None else FILE_READ_TIMEOUT_SECS,
627+
file_path, 'stat')
628+
if timeout is None:
629+
# Deadline assumes storage sustains at least ~5MB/s
630+
timeout = FILE_READ_TIMEOUT_SECS + int(file_size / (5 * 1024 * 1024))
631+
return _read_with_timeout(
632+
lambda: self._hash_file_contents(file_path, file_size),
633+
timeout, file_path, 'hash read')
634+
except FileReadTimeoutError:
635+
raise
636+
except Exception as e:
637+
logger.error(f"Error calculating hash for {file_path}: {str(e)}")
638+
return None
585639

640+
def _hash_file_contents(self, file_path, file_size):
641+
"""Blocking hash read; callers bound it via _read_with_timeout."""
642+
logger.info(f"Calculating hash for: {file_path}")
643+
hash_sha256 = hashlib.sha256()
644+
start_time = time.time()
645+
bytes_processed = 0
646+
647+
# NEVER skip hash - integrity checking is critical for all files
648+
# Use mmap for files > 100MB (5-10x faster), adaptive buffering for smaller files
649+
if file_size > 100 * 1024 * 1024: # 100MB threshold
650+
# Use memory-mapped I/O for large files (5-10x faster)
651+
logger.info(f"Hashing large file ({file_size/1024/1024/1024:.1f}GB) with mmap: {file_path}")
652+
try:
653+
with open(file_path, "rb") as f:
654+
# Map entire file into memory (OS handles paging)
655+
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
656+
hash_sha256.update(mm)
657+
bytes_processed = file_size
658+
except (OSError, ValueError) as e:
659+
# Fallback to buffered read if mmap fails
660+
logger.warning(f"mmap failed for {file_path}, falling back to buffered read: {e}")
586661
with open(file_path, "rb") as f:
662+
chunk_size = 16 * 1024 * 1024 # 16MB chunks
587663
while True:
588664
chunk = f.read(chunk_size)
589665
if not chunk:
590666
break
591667
hash_sha256.update(chunk)
592668
bytes_processed += len(chunk)
669+
else:
670+
# Use adaptive chunk sizes for smaller files
671+
chunk_size = 1024 * 1024 # 1MB chunks for files up to 1GB
593672

594-
# Log progress for large files every 100MB
595-
elapsed = time.time() - start_time
596-
if bytes_processed % (100 * 1024 * 1024) == 0:
597-
mb_processed = bytes_processed / (1024 * 1024)
598-
mb_per_sec = mb_processed / elapsed if elapsed > 0 else 0
599-
logger.info(f"Hash progress for {file_path}: {mb_processed:.0f}MB processed in {elapsed:.1f}s ({mb_per_sec:.1f}MB/s)")
600-
601-
total_time = time.time() - start_time
602-
if total_time > 10: # Log completion time for files that take more than 10 seconds
603-
mb_size = bytes_processed / (1024 * 1024)
604-
mb_per_sec = mb_size / total_time if total_time > 0 else 0
605-
logger.info(f"Hash complete for {file_path}: {mb_size:.1f}MB in {total_time:.1f}s ({mb_per_sec:.1f}MB/s)")
606-
607-
return hash_sha256.hexdigest()
608-
except Exception as e:
609-
logger.error(f"Error calculating hash for {file_path}: {str(e)}")
610-
return None
673+
# For files 1-10GB, use larger chunks
674+
if file_size > 1024 * 1024 * 1024:
675+
chunk_size = 4 * 1024 * 1024 # 4MB chunks
676+
677+
with open(file_path, "rb") as f:
678+
while True:
679+
chunk = f.read(chunk_size)
680+
if not chunk:
681+
break
682+
hash_sha256.update(chunk)
683+
bytes_processed += len(chunk)
684+
685+
# Log progress for large files every 100MB
686+
elapsed = time.time() - start_time
687+
if bytes_processed % (100 * 1024 * 1024) == 0:
688+
mb_processed = bytes_processed / (1024 * 1024)
689+
mb_per_sec = mb_processed / elapsed if elapsed > 0 else 0
690+
logger.info(f"Hash progress for {file_path}: {mb_processed:.0f}MB processed in {elapsed:.1f}s ({mb_per_sec:.1f}MB/s)")
691+
692+
total_time = time.time() - start_time
693+
if total_time > 10: # Log completion time for files that take more than 10 seconds
694+
mb_size = bytes_processed / (1024 * 1024)
695+
mb_per_sec = mb_size / total_time if total_time > 0 else 0
696+
logger.info(f"Hash complete for {file_path}: {mb_size:.1f}MB in {total_time:.1f}s ({mb_per_sec:.1f}MB/s)")
697+
698+
return hash_sha256.hexdigest()
611699

612700
def scan_files_parallel(self, file_paths, progress_callback=None, scan_paths=None, force_rescan=False):
613701
"""Scan multiple files in parallel using ThreadPoolExecutor with path-based optimization"""
@@ -799,9 +887,11 @@ def scan_file(self, file_path, force_rescan=False):
799887

800888
# Get basic file info first
801889
file_info = self.get_file_info(file_path)
802-
803-
# Calculate file hash
804-
file_hash = self.calculate_file_hash(file_path)
890+
891+
# Calculate file hash (reuse the stat from file_info; its fallback
892+
# dict reports size 0, in which case hash re-stats under its guard)
893+
file_hash = self.calculate_file_hash(
894+
file_path, file_size=file_info['file_size'] or None)
805895

806896
# Check cache if not forcing rescan
807897
if not force_rescan and self.database_path:
@@ -871,7 +961,7 @@ def scan_file(self, file_path, force_rescan=False):
871961
except Exception as e:
872962
scan_duration = time.time() - scan_start_time
873963
logger.error(f"Error scanning file {file_path}: {str(e)}")
874-
return {
964+
result = {
875965
'file_path': file_path,
876966
'file_size': 0,
877967
'file_type': 'unknown',
@@ -886,6 +976,11 @@ def scan_file(self, file_path, force_rescan=False):
886976
'has_warnings': False,
887977
'warning_details': None
888978
}
979+
# Persist the failure: without this the row stays 'pending' in the
980+
# non-chunked paths and an unreadable file is re-selected forever
981+
# (the re-stick loop of issue #70). No-op when database_path unset.
982+
self._save_to_cache(file_path, result)
983+
return result
889984
finally:
890985
# Clear current scan tracking
891986
with self.scan_lock:

pixelprobe/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Default version - this is the single source of truth
55

66

7-
_DEFAULT_VERSION = '2.7.0'
7+
_DEFAULT_VERSION = '2.7.1'
88

99

1010
# Allow override via environment variable for CI/CD, but default to the hardcoded version

0 commit comments

Comments
 (0)