]> ### 2026.09.14a - Fix the install-success message printing a hardcoded old version number instead of the version actually installed (the message lived inside a CDATA install script, so the &version; entity was never expanded there). ### 2026.09.14 - Add a plugin icon, shown on the Settings page and in Community Applications. ### 2026.09.10b - Settings page now polls a new status.php JSON endpoint every 3 seconds and updates status, counters and recent-history table in place, without a full page reload. ### 2026.09.10a - Fixed disks.ini parsing: section headers are written as ["name"], not [name], so the parity-status check always read "none" and refused to run. Confirmed against a live disks.ini. - Now reads disks.ini's exact `sectors` field for the parity device (512-byte units, matching mdcmd's offset unit) instead of deriving it from `size` in KB, removing a small rounding error. ### 2026.09.10 - Initial release. Parity Spot Check A disk drive with a crosshair scanning a random spot on it, representing continuous background parity spot checks. ]]> = 2 && $v[0] === '"' && substr($v, -1) === '"') { $v = stripcslashes(substr($v, 1, -1)); } $out[trim($k)] = $v; } return $out; } function q($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } function cfg_line($k, $v) { return $k . '="' . addcslashes((string)$v, "\\\"") . "\"\n"; } $msg = ''; $err = ''; $rawCfg = load_kv($cfgFile); $cfg = array_merge([ 'ENABLED' => 'no', 'SPOT_SIZE_MB' => '1024', 'INTERVAL_SECONDS' => '300', ], $rawCfg); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $action = $_POST['action'] ?? 'save'; if ($action === 'save') { $enabled = (($_POST['enabled'] ?? 'no') === 'yes') ? 'yes' : 'no'; $spotSize = (int)($_POST['spot_size_mb'] ?? 1024); $interval = (int)($_POST['interval_seconds'] ?? 300); if ($spotSize < 16 || $spotSize > 1048576) { $err = 'Spot size must be between 16 MB and 1,048,576 MB (1 TB).'; } elseif ($interval < 10 || $interval > 86400) { $err = 'Interval between spot checks must be between 10 and 86400 seconds.'; } else { if (!is_dir($cfgDir)) mkdir($cfgDir, 0755, true); $body = ''; $body .= cfg_line('ENABLED', $enabled); $body .= cfg_line('SPOT_SIZE_MB', $spotSize); $body .= cfg_line('INTERVAL_SECONDS', $interval); file_put_contents($cfgFile, $body, LOCK_EX); @chmod($cfgFile, 0644); exec(escapeshellcmd($rc) . ' restart >/dev/null 2>&1'); $msg = 'Settings saved and spot checker restarted.'; $cfg = array_merge($cfg, load_kv($cfgFile)); } } } $state = load_kv($stateFile); $status = $state['STATUS'] ?? 'UNKNOWN'; $detail = $state['DETAIL'] ?? 'No status has been recorded yet.'; $updated = $state['UPDATED'] ?? 'Never'; $statusClass = ($status === 'RUNNING') ? 'green' : (($status === 'ERROR') ? 'red' : (($status === 'WAITING') ? '#d58512' : '#888')); $history = []; if (is_file($historyFile)) { $lines = file($historyFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $lines = array_slice($lines, -50); foreach (array_reverse($lines) as $line) { $parts = explode('|', $line); if (count($parts) < 6) continue; $history[] = [ 'time' => $parts[0], 'offset' => $parts[1], 'size_mb' => $parts[2], 'errors' => $parts[3], 'duration_s' => $parts[4], 'note' => $parts[5], ]; } } ?>

Continuously spot-checks array parity in the background, read-only.

Current status:
Last updated:
Spot checks run since last (re)start Data scanned MB since last (re)start Mismatches found Last spot offset
● live
Recent spot checks
TimeArray offsetSizeMismatchesDurationNote
No spot checks recorded yet since the last start/reboot.
MB s
How this works
While enabled, a background daemon repeatedly picks a random sector offset within your array's parity-covered range and runs Unraid's own parity check engine (mdcmd check nocorrect <offset>) for approximately the configured spot size, then pauses it (mdcmd nocheck) and picks a new random offset. This continues indefinitely until you disable it here or uninstall the plugin.
Read-only, always: spot checks always run in NOCORRECT mode. Mismatches are reported and logged, but parity is never rewritten by this plugin. If mismatches are found, investigate (SMART status, cables, memory) and run a normal correcting parity check from the Unraid Main page yourself when you're confident which side is wrong.
It steps aside automatically if the array isn't started, if the parity disk isn't reporting OK, or if a real parity check/rebuild is already running (started by you or by Unraid) — it never cancels or interferes with an operation it didn't start.
Version note: the random-offset behaviour relies on the optional starting-offset argument to mdcmd check. On very old Unraid versions that may not be honoured, in which case spot checks will effectively always start from the beginning of the array; check the system log (search for "parity-spotcheck") to confirm offsets are actually moving.
Spot-check activity is also written to the system log (logger -t parity-spotcheck) so it survives even if the on-page counters reset after a reboot. A Warning notification is sent through your configured Unraid notification agents immediately if a spot check finds a mismatch.
]]>
= 2 && $v[0] === '"' && substr($v, -1) === '"') { $v = stripcslashes(substr($v, 1, -1)); } $out[trim($k)] = $v; } return $out; } $state = load_kv($stateFile); $history = []; if (is_file($historyFile)) { $lines = file($historyFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; $lines = array_slice($lines, -50); foreach (array_reverse($lines) as $line) { $parts = explode('|', $line); if (count($parts) < 6) continue; $history[] = [ 'time' => $parts[0], 'offset' => $parts[1], 'size_mb' => $parts[2], 'errors' => $parts[3], 'duration_s' => $parts[4], 'note' => $parts[5], ]; } } echo json_encode(['state' => $state, 'history' => $history]); ]]> "${STATE_FILE}.tmp" mv -f "${STATE_FILE}.tmp" "$STATE_FILE" } append_history() { umask 022 printf '%s|%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" "$6" >> "$HISTORY_FILE" tail -n "$HISTORY_MAX_LINES" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" 2>/dev/null && mv -f "${HISTORY_FILE}.tmp" "$HISTORY_FILE" } var_ini_get() { [[ -f "$VARINI" ]] || return 0 awk -F'"' -v k="$1" '$0 ~ "^"k"=" {print $2; exit}' "$VARINI" } disks_ini_get() { [[ -f "$DISKSINI" ]] || return 0 # Section headers are written as ["name"] (quotes included), not [name]. awk -F'"' -v sec="$1" -v k="$2" ' /^\[/ { line = $0 gsub(/^\["?/, "", line); gsub(/"?\]$/, "", line) infield = (line == sec) next } infield && $0 ~ "^"k"=" { print $2; exit } ' "$DISKSINI" } send_warning() { local subject="$1" description="$2" [[ -x "$NOTIFY" ]] || { log "Notification script not found: $NOTIFY"; return 1; } "$NOTIFY" -e "Unraid Parity Spot Check" -s "$subject" -d "$description" -i "warning" >/dev/null 2>&1 } # Uniform random integer in [0, max], read fresh from /dev/urandom each call. # (Deliberately not $RANDOM: inside a command-substitution subshell, some bash # builds reseed it identically on every call, which silently makes "random" # offsets constant. od/dev-urandom has no such gotcha and is always present on Linux.) random_units_upto() { local max="$1" (( max <= 0 )) && { echo 0; return; } local hi lo combined hi="$(od -An -N4 -tu4 /dev/urandom 2>/dev/null | tr -d ' \n')" lo="$(od -An -N4 -tu4 /dev/urandom 2>/dev/null | tr -d ' \n')" [[ "$hi" =~ ^[0-9]+$ ]] || hi=0 [[ "$lo" =~ ^[0-9]+$ ]] || lo=0 hi=$(( hi & 0x7FFFFFF )) combined=$(( (hi << 32) | lo )) (( combined < 0 )) && combined=$(( -combined )) echo $(( combined % (max + 1) )) } pick_offset_sectors() { local total_sectors="$1" spot_sectors="$2" local max_start=$(( total_sectors - spot_sectors )) (( max_start < 0 )) && max_start=0 local max_units=$(( max_start / 8 )) local units units="$(random_units_upto "$max_units")" echo $(( units * 8 )) } cleanup() { rm -f "$PID_FILE"; } on_term() { mdcmd nocheck >/dev/null 2>&1 || true write_state "STOPPED" "Stopped by request" log "Stopping; paused any in-flight spot check" exit 0 } trap cleanup EXIT trap on_term INT TERM printf '%s\n' "$$" > "$PID_FILE" TOTAL_SPOTS=0; TOTAL_MB=0; TOTAL_ERRORS=0; LAST_OFFSET_DESC="" load_cfg [[ "$ENABLED" == "yes" ]] || write_state "IDLE" "Spot checking is disabled" while true; do load_cfg if [[ "$ENABLED" != "yes" ]]; then write_state "IDLE" "Spot checking is disabled" sleep 5 continue fi mdstate="$(var_ini_get mdState)" if [[ "$mdstate" != "STARTED" ]]; then write_state "WAITING" "Array is not started (state=${mdstate:-unknown}); will retry" sleep "$INTERVAL_SECONDS" continue fi parity_status="$(disks_ini_get parity status)" if [[ "$parity_status" != "DISK_OK" ]]; then write_state "WAITING" "Parity disk is not reporting OK (status=${parity_status:-none}); nothing valid to spot-check" sleep "$INTERVAL_SECONDS" continue fi mdresync="$(var_ini_get mdResync)" if [[ -n "$mdresync" && "$mdresync" != "0" ]]; then action="$(var_ini_get mdResyncAction)" write_state "WAITING" "Another array check/rebuild is already running (${action:-unknown}); waiting for it to finish" sleep "$POLL_SECONDS" continue fi total_sectors="$(disks_ini_get parity sectors)" if [[ ! "$total_sectors" =~ ^[0-9]+$ ]] || (( total_sectors == 0 )); then # Fall back to size (KB) * 2, then to var.ini's resync size (KB) * 2. parity_size_kb="$(disks_ini_get parity size)" [[ "$parity_size_kb" =~ ^[0-9]+$ ]] || parity_size_kb="$(var_ini_get mdResyncSize)" if [[ "$parity_size_kb" =~ ^[0-9]+$ ]] && (( parity_size_kb > 0 )); then total_sectors=$(( parity_size_kb * 2 )) else total_sectors=0 fi fi if (( total_sectors == 0 )); then write_state "ERROR" "Could not determine parity size from $DISKSINI or $VARINI" log "ERROR: could not determine parity size" sleep "$INTERVAL_SECONDS" continue fi spot_sectors=$(( SPOT_SIZE_MB * 2048 )) (( spot_sectors > total_sectors )) && spot_sectors=$(( (total_sectors / 8) * 8 )) offset_sectors="$(pick_offset_sectors "$total_sectors" "$spot_sectors")" offset_mb=$(( offset_sectors / 2048 )) LAST_OFFSET_DESC="sector ${offset_sectors} (~${offset_mb} MB into the array)" errs_before="$(var_ini_get sbSyncErrs)"; [[ "$errs_before" =~ ^[0-9]+$ ]] || errs_before=0 start_epoch="$(date +%s)" start_text="$(date '+%Y-%m-%d %H:%M:%S')" mdcmd check nocorrect "$offset_sectors" >/dev/null 2>&1 log "Started spot check at sector $offset_sectors (~${offset_mb} MB), target size ${SPOT_SIZE_MB} MB" write_state "RUNNING" "Spot-checking parity starting at $LAST_OFFSET_DESC" sleep 1 pos0="$(var_ini_get mdResyncPos)"; [[ "$pos0" =~ ^[0-9]+$ ]] || pos0=0 target_delta_kb=$(( SPOT_SIZE_MB * 1024 )) note="completed" while true; do sleep "$POLL_SECONDS" mdresync_now="$(var_ini_get mdResync)" if [[ -z "$mdresync_now" || "$mdresync_now" == "0" ]]; then note="check ended on its own (reached end of array or was stopped elsewhere)" break fi pos_now="$(var_ini_get mdResyncPos)"; [[ "$pos_now" =~ ^[0-9]+$ ]] || pos_now=$pos0 delta=$(( pos_now - pos0 )) if (( delta >= target_delta_kb )); then break fi load_cfg if [[ "$ENABLED" != "yes" ]]; then note="stopped: disabled while running" break fi done mdresync_now="$(var_ini_get mdResync)" if [[ -n "$mdresync_now" && "$mdresync_now" != "0" ]]; then mdcmd nocheck >/dev/null 2>&1 fi errs_after="$(var_ini_get sbSyncErrs)"; [[ "$errs_after" =~ ^[0-9]+$ ]] || errs_after=$errs_before errs_delta=$(( errs_after - errs_before )) (( errs_delta < 0 )) && errs_delta=0 end_epoch="$(date +%s)" duration=$(( end_epoch - start_epoch )) TOTAL_SPOTS=$(( TOTAL_SPOTS + 1 )) TOTAL_MB=$(( TOTAL_MB + SPOT_SIZE_MB )) TOTAL_ERRORS=$(( TOTAL_ERRORS + errs_delta )) append_history "$start_text" "sector $offset_sectors (~${offset_mb} MB)" "$SPOT_SIZE_MB" "$errs_delta" "$duration" "$note" log "Spot check finished: offset=${offset_sectors} size=${SPOT_SIZE_MB}MB errors=${errs_delta} duration=${duration}s note=${note}" if (( errs_delta > 0 )); then send_warning "Parity mismatch found" "A parity spot check starting at sector ${offset_sectors} (~${offset_mb} MB into the array) found ${errs_delta} mismatch(es). No corrections were made (NOCORRECT mode). Investigate before running a correcting parity check." write_state "RUNNING" "Last spot found ${errs_delta} mismatch(es) at $LAST_OFFSET_DESC — see history below" else write_state "RUNNING" "Last spot clean at $LAST_OFFSET_DESC; resting ${INTERVAL_SECONDS}s before the next one" fi load_cfg [[ "$ENABLED" == "yes" ]] && sleep "$INTERVAL_SECONDS" done ]]> /dev/null)" [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null } start() { if is_running; then echo "$NAME is already running" return 0 fi mkdir -p "$STATE_DIR" nohup "$SCRIPT" >/dev/null 2>&1 & sleep 0.2 echo "$NAME started" } stop() { if is_running; then local pid pid="$(cat "$PID_FILE")" kill "$pid" 2>/dev/null || true for _ in 1 2 3 4 5; do kill -0 "$pid" 2>/dev/null || break sleep 0.2 done kill -9 "$pid" 2>/dev/null || true fi # Always pause any in-flight spot check, even if the daemon didn't exit cleanly. mdcmd nocheck >/dev/null 2>&1 || true rm -f "$PID_FILE" echo "$NAME stopped" } case "${1:-}" in start) start ;; stop) stop ;; restart) stop; start ;; status) if is_running; then echo "running (pid $(cat "$PID_FILE"))" exit 0 else echo "stopped" exit 1 fi ;; *) echo "Usage: $0 {start|stop|restart|status}"; exit 2 ;; esac ]]> "$CFG" <<'CFGEOF' ENABLED="no" SPOT_SIZE_MB="1024" INTERVAL_SECONDS="300" CFGEOF chmod 0644 "$CFG" fi "$PLGDIR/scripts/rc.$NAME" stop >/dev/null 2>&1 || true "$PLGDIR/scripts/rc.$NAME" start >/dev/null 2>&1 || true ]]> echo "-----------------------------------------------------------" echo " Parity Spot Check installed" echo " Version: &version;" echo " Mode: always NOCORRECT (read-only, never writes parity)" echo " Default: disabled; enable it under Settings -> User Utilities" echo " Settings: Settings -> User Utilities -> Parity Spot Check" echo "-----------------------------------------------------------" /dev/null 2>&1 || true; fi mdcmd nocheck >/dev/null 2>&1 || true rm -rf "$PLGDIR" "/var/local/$NAME" rm -f "/var/run/$NAME.pid" echo "Parity Spot Check removed. Any in-flight spot check was paused. Saved settings under /boot/config/plugins/$NAME are preserved." ]]>