From b8f3a0ecd31de31acdd479916f7da4496a3578ad Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 18 Mar 2026 14:49:12 +0800 Subject: [PATCH] fix(clean): avoid container cache scan stalls Add a lightweight top-level entry cap to Containers and Group Containers scanning. When a cache directory exceeds the threshold, skip the expensive per-item du/find size scan and take a partial-size / "cleaned" output path instead. Replace find-piped-to-read loops with pure-bash glob iteration to cut external process overhead. Closes #586 --- lib/clean/user.sh | 157 +++++++++++++++++++++++++++---------- tests/clean_user_core.bats | 63 +++++++++++++++ 2 files changed, 180 insertions(+), 40 deletions(-) diff --git a/lib/clean/user.sh b/lib/clean/user.sh index 5b84f22..62efbb0 100644 --- a/lib/clean/user.sh +++ b/lib/clean/user.sh @@ -484,6 +484,32 @@ clean_support_app_data() { } # App caches (merged: macOS system caches + Sandboxed apps). +cache_top_level_entry_count_capped() { + local dir="$1" + local cap="${2:-101}" + local count=0 + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + local item + for item in "$dir"/*; do + [[ -e "$item" ]] || continue + count=$((count + 1)) + if ((count >= cap)); then + break + fi + done + + eval "$_nullglob_state" + eval "$_dotglob_state" + + [[ "$count" =~ ^[0-9]+$ ]] || count=0 + printf '%s\n' "$count" +} + clean_app_caches() { start_section_spinner "Scanning app caches..." @@ -516,6 +542,7 @@ clean_app_caches() { [[ ! -d "$containers_dir" ]] && return 0 start_section_spinner "Scanning sandboxed apps..." local total_size=0 + local total_size_partial=false local cleaned_count=0 local found_any=false @@ -529,12 +556,22 @@ clean_app_caches() { stop_section_spinner if [[ "$found_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}dry${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Sandboxed app caches${NC}, ${YELLOW}$size_human dry${NC}" + fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}cleaned${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Sandboxed app caches${NC}, ${GREEN}$size_human${NC}" + fi fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) @@ -561,20 +598,35 @@ process_container_cache() { local cache_dir="$container_dir/Data/Library/Caches" [[ -d "$cache_dir" ]] || return 0 [[ -L "$cache_dir" ]] && return 0 - # Fast non-empty check. - if find "$cache_dir" -mindepth 1 -maxdepth 1 -print -quit 2> /dev/null | grep -q .; then + local item_count + item_count=$(cache_top_level_entry_count_capped "$cache_dir" 101) + [[ "$item_count" =~ ^[0-9]+$ ]] || item_count=0 + [[ "$item_count" -eq 0 ]] && return 0 + + if [[ "$item_count" -le 100 ]]; then local size - size=$(get_path_size_kb "$cache_dir") + size=$(get_path_size_kb "$cache_dir" 2> /dev/null || echo "0") + [[ "$size" =~ ^[0-9]+$ ]] || size=0 total_size=$((total_size + size)) - found_any=true - cleaned_count=$((cleaned_count + 1)) - if [[ "$DRY_RUN" != "true" ]]; then - local item - while IFS= read -r -d '' item; do - [[ -e "$item" ]] || continue - safe_remove "$item" true || true - done < <(command find "$cache_dir" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - fi + else + total_size_partial=true + fi + + found_any=true + cleaned_count=$((cleaned_count + 1)) + if [[ "$DRY_RUN" != "true" ]]; then + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + local item + for item in "$cache_dir"/*; do + [[ -e "$item" ]] || continue + safe_remove "$item" true || true + done + eval "$_nullglob_state" + eval "$_dotglob_state" fi } @@ -588,6 +640,7 @@ clean_group_container_caches() { start_section_spinner "Scanning Group Containers..." local total_size=0 + local total_size_partial=false local cleaned_count=0 local found_any=false @@ -642,42 +695,56 @@ clean_group_container_caches() { continue fi - # Build non-protected candidate items for cleanup. - local -a items_to_clean=() local item - while IFS= read -r -d '' item; do - [[ -e "$item" ]] || continue - [[ -L "$item" ]] && continue - if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then - continue - else - items_to_clean+=("$item") - fi - done < <(command find "$candidate" -mindepth 1 -maxdepth 1 -print0 2> /dev/null || true) - - [[ ${#items_to_clean[@]} -gt 0 ]] || continue + local quick_count + quick_count=$(cache_top_level_entry_count_capped "$candidate" 101) + [[ "$quick_count" =~ ^[0-9]+$ ]] || quick_count=0 + [[ "$quick_count" -eq 0 ]] && continue local candidate_size_kb=0 local candidate_changed=false - if [[ "$DRY_RUN" == "true" ]]; then - for item in "${items_to_clean[@]}"; do - local item_size - item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 - [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 + local _nullglob_state + local _dotglob_state + _nullglob_state=$(shopt -p nullglob || true) + _dotglob_state=$(shopt -p dotglob || true) + shopt -s nullglob dotglob + + if [[ "$quick_count" -gt 100 ]]; then + total_size_partial=true + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi candidate_changed=true - candidate_size_kb=$((candidate_size_kb + item_size)) + if [[ "$DRY_RUN" != "true" ]]; then + safe_remove "$item" true 2> /dev/null || true + fi done else - for item in "${items_to_clean[@]}"; do + for item in "$candidate"/*; do + [[ -e "$item" ]] || continue + [[ -L "$item" ]] && continue + if should_protect_path "$item" 2> /dev/null || is_path_whitelisted "$item" 2> /dev/null; then + continue + fi local item_size item_size=$(get_path_size_kb "$item" 2> /dev/null) || item_size=0 [[ "$item_size" =~ ^[0-9]+$ ]] || item_size=0 + if [[ "$DRY_RUN" == "true" ]]; then + candidate_changed=true + candidate_size_kb=$((candidate_size_kb + item_size)) + continue + fi if safe_remove "$item" true 2> /dev/null; then candidate_changed=true candidate_size_kb=$((candidate_size_kb + item_size)) fi done fi + eval "$_nullglob_state" + eval "$_dotglob_state" if [[ "$candidate_changed" == "true" ]]; then total_size=$((total_size + candidate_size_kb)) @@ -690,12 +757,22 @@ clean_group_container_caches() { stop_section_spinner if [[ "$found_any" == "true" ]]; then - local size_human - size_human=$(bytes_to_human "$((total_size * 1024))") if [[ "$DRY_RUN" == "true" ]]; then - echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}dry${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${YELLOW}${ICON_DRY_RUN}${NC} Group Containers logs/caches${NC}, ${YELLOW}$size_human dry${NC}" + fi else - echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}" + if [[ "$total_size_partial" == "true" ]]; then + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}cleaned${NC}" + else + local size_human + size_human=$(bytes_to_human "$((total_size * 1024))") + echo -e " ${GREEN}${ICON_SUCCESS}${NC} Group Containers logs/caches${NC}, ${GREEN}$size_human${NC}" + fi fi files_cleaned=$((files_cleaned + cleaned_count)) total_size_cleaned=$((total_size_cleaned + total_size)) diff --git a/tests/clean_user_core.bats b/tests/clean_user_core.bats index 5ad137d..ec21cbc 100644 --- a/tests/clean_user_core.bats +++ b/tests/clean_user_core.bats @@ -193,6 +193,39 @@ EOF [[ "$output" != *"App caches"* ]] || [[ "$output" == *"already clean"* ]] } +@test "clean_app_caches skips expensive size scans for large sandboxed caches" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +safe_clean() { :; } +should_protect_data() { return 1; } +is_critical_system_component() { return 1; } +get_path_size_kb() { + echo "SHOULD_NOT_SIZE_SCAN" + return 0 +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Containers/com.example.large/Data/Library/Caches" +for i in $(seq 1 101); do + touch "$HOME/Library/Containers/com.example.large/Data/Library/Caches/file-$i.tmp" +done + +clean_app_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Sandboxed app caches"* ]] + [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]] +} + @test "clean_application_support_logs counts nested directory contents in dry-run size summary" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true bash --noprofile --norc <<'EOF' set -euo pipefail @@ -451,6 +484,36 @@ EOF [[ "$output" != *"Group Containers logs/caches"* ]] } +@test "clean_group_container_caches skips per-item size scans for large candidates" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" DRY_RUN=true /bin/bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/user.sh" +start_section_spinner() { :; } +stop_section_spinner() { :; } +bytes_to_human() { echo "0B"; } +note_activity() { :; } +get_path_size_kb() { + echo "SHOULD_NOT_SIZE_SCAN" + return 0 +} +files_cleaned=0 +total_size_cleaned=0 +total_items=0 + +mkdir -p "$HOME/Library/Group Containers/group.com.example.large/Library/Caches" +for i in $(seq 1 101); do + touch "$HOME/Library/Group Containers/group.com.example.large/Library/Caches/file-$i.tmp" +done + +clean_group_container_caches +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"Group Containers logs/caches"* ]] + [[ "$output" != *"SHOULD_NOT_SIZE_SCAN"* ]] +} + @test "clean_finder_metadata respects protection flag" { run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" PROTECT_FINDER_METADATA=true /bin/bash --noprofile --norc <<'EOF' set -euo pipefail