RSS Amplifier

Morgan Davis · Mar 8, 2026

Cleaning Up After VS Code

0
Sign in to vote or save

Morgan Davis · Morgan Davis

By Morgan Davis

08 Mar 2026 • updated 22d

VS Code Logo

If you use Visual Studio Code's remote development features — SSH, Dev Containers, WSL — you have probably noticed that the VSCode server does not clean up after itself. Every time it connects to a remote host, it unpacks a versioned server binary under ~/.vscode-server, writes runtime files to /tmp, and spawns background processes that can outlive your session. Over time, especially on shared development servers where multiple developers connect and disconnect regularly, this accumulates into gigabytes of orphaned data spread across home directories and /tmp, eats up RAM, and digs into swap space.

The problem is compounded on shared hosts. A server that five developers remote into will have five separate ~/.vscode-server trees, each potentially containing outdated bin/, cli/, and data/ directories from every VS Code version those developers have used. The /tmp directory fills up with mcp-*, vscode-*, code-*, and node-compile-cache-* and other artifacts that are never reclaimed. Processes from disconnected sessions can linger almost indefinitely.

Microsoft provides no official tooling for this. The expectation seems to be that you either manage it manually or ignore it until disk pressure becomes a problem.

vs-clean.sh

vs-clean.sh is a POSIX shell script that handles it properly. It terminates any running VSCode server processes for the target user, removes the versioned server directories and runtime files from ~/.vscode-server (leaving the extensions directory untouched), and clears all VSCode-related artefacts from /tmp. Key features:

  • Dry run mode (-n) shows exactly what would be removed and how much space would be freed, before touching anything.
  • Multi-user support (-u alice,bob,carol) lets an administrator clean up multiple users in a single invocation.
  • Auto-detection (--all) scans /tmp/vscode-* ownership to identify which users have active VSCode server artefacts and cleans them all without requiring a manual list.
  • Graceful process termination with a SIGKILL fallback if processes do not exit within 10 seconds.
  • No dependencies beyond a standard POSIX environment — no Python, no Node, no package manager required.

How to Use

It is designed to run as root manually, in a cron job on shared development hosts, or by individual users cleaning their own environment.

If you have open VS Code sessions, save your work and close VS Code before running this, just to be safe. However, if you forget, VS Code will sense it has lost its connection to the server and want to reconnect. It is resilient enough to retain unsaved work so you can resume where you left off.

After cleaning, the VS Code client will signal the remote host to install a fresh server binary. Your previously installed extensions are always preserved.

#!/bin/sh
#
# vs-clean.sh - Cleans up after Visual Studio Code server
#
# Kills running VSCode server processes and removes:
#
#   - .cli.* and code-* files and the bin and cli directories
#     from ~/.vscode-server (but not extensions)
#
#   - regenerable caches/logs/workspace-storage from ~/.vscode-server/data,
#     while preserving user and machine settings (see DATA_PURGE_DIRS)
#
#   - code-*, mcp-*, node-compile-cache*, ssh-*, and
#     vscode-* files/directories from /tmp (including broken/dangling
#     symlinks, e.g. vscode-ssh-auth-sock-* pointing at an agent socket
#     that no longer exists)
#
set -e
DRY_RUN=0
QUIET=0
USER_LIST=""
PROG=$(basename "$0")
readonly PROG
readonly KILL_WAIT_SECONDS=10   # grace period after SIGTERM before SIGKILL
readonly KIB_PER_MIB=1024
readonly KIB_PER_GIB=1048576
# Regenerable subpaths under ~/.vscode-server/data. These are caches, logs,
# and per-workspace state that VSCode rebuilds automatically. Anything not
# listed here is preserved, which deliberately includes Machine/settings.json,
# User/settings.json, keybindings, snippets, globalStorage, profile configs,
# and machineid.
readonly DATA_PURGE_DIRS="logs CachedExtensionVSIXs CachedProfilesData User/workspaceStorage User/History"
usage() {
    cat << EOF
Usage: $PROG [OPTIONS]
Cleanup operations:
    1. Kill any VSCode server processes for the user
    2. Remove from ~/.vscode-server:
       - .cli.* files
       - code-* files
       - bin directory
       - cli directory
       - data/ caches only (logs, CachedExtensionVSIXs, CachedProfilesData,
         User/workspaceStorage, User/History); settings are preserved
    3. Remove from /tmp (user-owned only):
       - code-* files and directories
       - mcp-* files and directories
       - node-compile-cache* files and directories
       - ssh-* files and directories
       - vscode-* files and directories (including broken symlinks, e.g.
         stale vscode-ssh-auth-sock-* links)
Note: The extensions directory and persistent settings under data/
      (Machine/settings.json, User/settings.json, keybindings, snippets,
      globalStorage, machineid) are not affected.
Options:
    -n, --dry-run           Display what would happen without making changes
    -u, --user <id[,id...]> Perform cleanup on one or more users (comma-separated)
    -a, --all               Detect users from /tmp/vscode-* ownership and clean all
    -q, --quiet             Suppress progress output
    -h, --help              Show this help message
Examples:
    $PROG                         # Clean current user's directory
    $PROG -n                      # Dry run for current user
    $PROG -u username             # Clean specified user's directory
    $PROG -u alice,bob,carol      # Clean multiple users
    $PROG -a                      # Clean all users with /tmp/vscode-* files
    $PROG -n -u alice,bob         # Dry run for multiple users
EOF
    exit 0
}
error_exit() {
    echo "Error: $1" >&2
    exit 1
}
# Print to stderr unless quiet. Explicit return 0: under set -e a quiet-mode
# short-circuit ([ ] && echo) would otherwise propagate status 1 to the caller
# and abort the script.
output() {
    [ "$QUIET" -eq 0 ] && echo "$1" >&2
    return 0
}
# True if a directory entry exists at all, including broken/dangling symlinks.
# Plain [ -e ] follows symlinks and reports false for a dangling link, which
# would silently skip stale sockets/links like vscode-ssh-auth-sock-*.
entry_exists() {
    [ -e "$1" ] || [ -L "$1" ]
}
# Disk usage in KiB; always emits an integer so arithmetic never sees an empty
# value when the path has vanished or du fails (e.g. a broken symlink).
get_disk_usage() {
    size=$(du -sk "$1" 2>/dev/null | awk 'NR==1{print $1}')
    echo "${size:-0}"
}
format_disk_usage() {
    kb=$1
    if [ "$kb" -ge "$KIB_PER_GIB" ]; then
        echo "$((kb / KIB_PER_GIB)) GB"
    elif [ "$kb" -ge "$KIB_PER_MIB" ]; then
        echo "$((kb / KIB_PER_MIB)) MB"
    else
        echo "${kb} KB"
    fi
}
# Resolve unique usernames from /tmp/vscode-* ownership. Requires read access
# to /tmp. Returns a space-separated list.
collect_vscode_users() {
    found=""
    for item in /tmp/vscode-*; do
        entry_exists "$item" || continue
        # Server-generated names are predictable (no spaces/newlines), so parsing
        # ls is safe; stat(1) format flags differ between GNU and BSD.
        # shellcheck disable=SC2012
        owner=$(ls -ld "$item" 2>/dev/null | awk 'NR==1{print $3}')
        [ -z "$owner" ] && continue
        case "$owner" in
            *[!0-9]*) username="$owner" ;;                                    # already a name
            *) username=$(getent passwd "$owner" 2>/dev/null | cut -d: -f1) ;; # numeric uid
        esac
        [ -z "$username" ] && continue
        case " $found " in
            *" $username "*) continue ;;
        esac
        found="${found:+$found }$username"
    done
    echo "$found"
}
kill_vscode_processes() {
    user_id=$1
    vscode_dir=$2
    pattern="${vscode_dir}/cli/servers/Stable-"
    pids=$(pgrep -u "$user_id" -f "$pattern" 2>/dev/null || true)
    if [ -z "$pids" ]; then
        output "No VSCode server processes found for $user_id"
        output ""
        return 0
    fi
    if [ "$DRY_RUN" -eq 0 ]; then
        output "Terminating VSCode server processes for $user_id..."
        for pid in $pids; do
            kill "$pid" 2>/dev/null || true
        done
        wait_count=0
        while [ "$wait_count" -lt "$KILL_WAIT_SECONDS" ]; do
            remaining_pids=$(pgrep -u "$user_id" -f "$pattern" 2>/dev/null || true)
            [ -z "$remaining_pids" ] && { output "  All processes terminated"; output ""; return 0; }
            sleep 1
            wait_count=$((wait_count + 1))
        done
        remaining_pids=$(pgrep -u "$user_id" -f "$pattern" 2>/dev/null || true)
        if [ -n "$remaining_pids" ]; then
            output "  Forcing termination with KILL signal..."
            for pid in $remaining_pids; do
                kill -9 "$pid" 2>/dev/null || true
            done
            sleep 1
        fi
        output "  Process cleanup complete"
    else
        output "Would terminate VSCode server processes for $user_id:"
        for pid in $pids; do
            output "  - $pid"
        done
    fi
    output ""
}
remove_files() {
    vscode_dir=$1
    pattern=$2
    description=$3
    total_size=0
    found=0
    output "Removing $description..."
    for file in "$vscode_dir"/$pattern; do
        if entry_exists "$file"; then
            found=1
            file_size=$(get_disk_usage "$file")
            total_size=$((total_size + file_size))
            output "  - $(basename "$file") ($(format_disk_usage "$file_size"))"
            [ "$DRY_RUN" -eq 0 ] && rm -f "$file"
        fi
    done
    # Track presence separately from size: empty files and symlinks measure 0 KB,
    # so a size-based test would list an entry and then deny it was found.
    [ "$found" -eq 0 ] && output "  No $description found"
    output ""
    echo "$total_size"
}
remove_directory() {
    base_dir=$1
    dir_name=$2
    dir_path="$base_dir/$dir_name"
    dir_size=0
    if [ -d "$dir_path" ]; then
        dir_size=$(get_disk_usage "$dir_path")
        output "Removing $dir_name directory ($(format_disk_usage "$dir_size"))..."
        [ "$DRY_RUN" -eq 0 ] && rm -rf "$dir_path"
    else
        output "$dir_name directory not found"
    fi
    output ""
    echo "$dir_size"
}
# Selectively clean ~/.vscode-server/data: purge the regenerable subpaths in
# DATA_PURGE_DIRS while leaving persistent settings untouched. Prints total KiB
# freed to stdout.
clean_data_dir() {
    vscode_dir=$1
    data_dir="$vscode_dir/data"
    total=0
    if [ ! -d "$data_dir" ]; then
        output "data directory not found"
        output ""
        echo 0
        return 0
    fi
    output "Cleaning data caches (settings preserved)..."
    output ""
    for sub in $DATA_PURGE_DIRS; do
        sub_size=$(remove_directory "$data_dir" "$sub")
        total=$((total + sub_size))
    done
    echo "$total"
}
# Remove user-owned VSCode temp items from /tmp. A single ownership-filtered
# find covers every prefix, avoiding one process per pattern. -user matches
# on the entry itself (lstat) in find's default physical mode, so ownership
# filtering works correctly on symlinks too, including broken ones.
remove_tmp_items() {
    user_id=$1
    total_size=0
    found=0
    output "Cleaning /tmp for VSCode server files owned by $user_id..."
    items=$(find /tmp -maxdepth 1 -user "$user_id" \( \
        -name 'code-*' -o -name 'mcp-*' -o -name 'node-compile-cache*' \
        -o -name 'ssh-*' -o -name 'vscode-*' \) 2>/dev/null || true)
    # Iterate newline-delimited find output; restore IFS afterwards.
    saved_ifs=$IFS
    IFS='
'
    for item in $items; do
        # A filename may legally contain a newline, which splits one find result
        # into several words; trailing fragments are relative paths that would
        # otherwise be resolved against the caller's CWD and deleted. Only act on
        # absolute /tmp entries, and never on the /tmp root itself.
        case "$item" in
            /tmp/?*) ;;
            *) continue ;;
        esac
        entry_exists "$item" || continue
        found=1
        item_size=$(get_disk_usage "$item")
        total_size=$((total_size + item_size))
        output "  - $(basename "$item") ($(format_disk_usage "$item_size"))"
        [ "$DRY_RUN" -eq 0 ] && rm -rf "$item"
    done
    IFS=$saved_ifs
    [ "$found" -eq 0 ] && output "  No matching items found in /tmp"
    output ""
    echo "$total_size"
}
cleanup_user() {
    target_user=$1
    target_home=$(getent passwd "$target_user" 2>/dev/null | cut -d: -f6)
    [ -z "$target_home" ] && { echo "Error: User '$target_user' not found -- skipping" >&2; return 1; }
    vscode_dir="${target_home}/.vscode-server"
    if [ ! -d "$vscode_dir" ]; then
        output "Directory does not exist, skipping: $vscode_dir"
        output ""
        return 0
    fi
    if [ ! -w "$vscode_dir" ]; then
        echo "Error: No write permission for: $vscode_dir -- skipping" >&2
        return 1
    fi
    output "=========================================="
    output "Cleaning up user: $target_user"
    output "=========================================="
    output ""
    kill_vscode_processes "$target_user" "$vscode_dir"
    output "Cleaning: $vscode_dir"
    output ""
    before_kb=$(get_disk_usage "$vscode_dir")
    output ".vscode-server disk usage before cleaning: $(format_disk_usage "$before_kb")"
    output ""
    cli_files_size=$(remove_files "$vscode_dir" ".cli.*" ".cli.* files")
    code_files_size=$(remove_files "$vscode_dir" "code-*" "code-* files")
    bin_dir_size=$(remove_directory "$vscode_dir" "bin")
    cli_dir_size=$(remove_directory "$vscode_dir" "cli")
    data_dir_size=$(clean_data_dir "$vscode_dir")
    if [ "$DRY_RUN" -eq 0 ]; then
        after_kb=$(get_disk_usage "$vscode_dir")
        output ".vscode-server disk usage after cleaning: $(format_disk_usage "$after_kb")"
        output ""
    fi
    tmp_size=$(remove_tmp_items "$target_user")
    total_freed=$((cli_files_size + code_files_size + bin_dir_size + cli_dir_size + data_dir_size + tmp_size))
    if [ "$DRY_RUN" -eq 0 ]; then
        output "Total space freed: $(format_disk_usage "$total_freed")"
    else
        output "Space that would be freed: $(format_disk_usage "$total_freed")"
    fi
    output ""
}
# Parse arguments
while [ $# -gt 0 ]; do
    case "$1" in
        -n|--dry-run) DRY_RUN=1; shift ;;
        -q|--quiet) QUIET=1; shift ;;
        -h|--help) usage ;;
        -a|--all)
            detected=$(collect_vscode_users)
            [ -z "$detected" ] && error_exit "No /tmp/vscode-* files found; no users to clean"
            USER_LIST="$detected"
            shift
            ;;
        -u|--user)
            [ -z "$2" ] && error_exit "Option -u requires a user ID argument"
            # Normalise comma-separated list to space-separated
            parsed=$(echo "$2" | tr ',' ' ')
            USER_LIST="${USER_LIST:+$USER_LIST }$parsed"
            shift 2
            ;;
        *) error_exit "Unknown option: $1. Use -h for help." ;;
    esac
done
# Default to current user when no explicit list was built
if [ -z "$USER_LIST" ]; then
    USER_LIST=$(id -un)
fi
[ "$DRY_RUN" -eq 1 ] && { output "=== DRY RUN MODE - No changes will be made ==="; output ""; }
# || true: a single unresolvable/unwritable user must not abort the whole run.
for user in $USER_LIST; do
    cleanup_user "$user" || true
done
output "Status: $([ "$DRY_RUN" -eq 1 ] && echo "Dry run completed successfully" || echo "Cleanup completed successfully")"
exit 0
Explore

Read the original on morgandavis.net

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.