By Morgan Davis
08 Mar 2026 • updated 22d
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
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.