Files
tekton/addons/godot_ai/utils/uv_cache_cleanup.gd
T

162 lines
6.3 KiB
GDScript

@tool
class_name McpUvCacheCleanup
extends RefCounted
## Sweeps stale `.tmp*` build venvs out of `%LOCALAPPDATA%\uv\cache\builds-v0`.
##
## Background
## ----------
## When Claude Desktop's MCP launcher invokes `uvx mcp-proxy ...` to talk to
## a running godot-ai server, uv builds an ephemeral venv under
## `builds-v0\.tmpXXXXXX\`. To save disk it hard-links shared C extensions
## (notably `pydantic_core/_pydantic_core.cp313-win_amd64.pyd`) from
## `archive-v0\<hash>\Lib\site-packages\...` into the build venv.
##
## If the godot-ai server's own Python child has that same `.pyd` mapped via
## `LoadLibrary` (it does — godot-ai imports pydantic), the file is locked
## under BOTH paths because hard links share the inode and Windows tracks
## handles per-file, not per-path. uv's post-install cleanup of the build
## venv then dies with:
##
## Failed to install: pywin32-311-cp313-cp313-win_amd64.whl (pywin32==311)
## Caused by: failed to remove directory `...\.tmpXXXXXX\Lib\site-packages\pywin32-311.data`
## 다른 프로세스가 파일을 사용 중이기 때문에 ... (os error 32)
##
## (the `pywin32` mention is incidental — the actual lock is on the earlier
## hard-linked `_pydantic_core.pyd`; pywin32 is just the last install step
## in the wheel-resolution order that triggers the cleanup pass).
##
## What this does
## --------------
## After the plugin stops/restarts the managed server — i.e. the moment when
## the archive-v0 `.pyd` mappings drop and the hard-linked builds-v0 copy
## becomes deletable — sweep `builds-v0\` for `.tmp*` orphans:
##
## 1. Rename each `.tmpXXX` to `_dead_.tmpXXX`. Rename succeeds even when
## AV scanners hold the file open without `FILE_SHARE_DELETE` (Defender
## and Softcamp SDS both do this), so this step always advances.
## 2. Recursively remove the renamed dir, swallowing per-file
## access-denied. Anything still genuinely locked is left for the next
## sweep — uv won't reuse the renamed name, so no future build collides.
##
## No-op on non-Windows (uv's hard-link strategy only causes this lock
## pattern on NTFS) and when the cache directory doesn't exist.
const DEAD_PREFIX := "_dead_"
const TMP_PREFIX := ".tmp"
## Live entrypoint. Resolves `%LOCALAPPDATA%\uv\cache\builds-v0` and runs
## the sweep. Returns the same counts the testable `purge_directory` returns,
## or all zeros on non-Windows / missing cache.
static func purge_stale_builds() -> Dictionary:
if OS.get_name() != "Windows":
return _empty_result()
var local_appdata := OS.get_environment("LOCALAPPDATA")
if local_appdata.is_empty():
return _empty_result()
var builds_root := local_appdata.replace("\\", "/").path_join("uv/cache/builds-v0")
return purge_directory(builds_root)
## Pure-ish entrypoint that takes a directory path. Returns
## `{ "scanned": int, "renamed": int, "deleted": int, "remaining": int }`.
## - `scanned`: how many `.tmp*` subdirs we saw on entry.
## - `renamed`: how many we successfully renamed to `_dead_*`.
## - `deleted`: how many we then fully removed.
## - `remaining`: how many `_dead_*` dirs are still on disk after the sweep
## (left for the next call to retry).
##
## Errors are swallowed — the caller is on a server-stop hot path and
## must not raise.
static func purge_directory(builds_root: String) -> Dictionary:
var result := _empty_result()
if not DirAccess.dir_exists_absolute(builds_root):
return result
var dir := DirAccess.open(builds_root)
if dir == null:
return result
dir.include_hidden = true
## Pass 1: collect names. Iterating + renaming in the same walk would
## confuse DirAccess's internal cursor on NTFS.
var tmp_names: Array[String] = []
var dead_names: Array[String] = []
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if dir.current_is_dir() and not (entry == "." or entry == ".."):
if entry.begins_with(TMP_PREFIX):
tmp_names.append(entry)
elif entry.begins_with(DEAD_PREFIX):
dead_names.append(entry)
entry = dir.get_next()
dir.list_dir_end()
result.scanned = tmp_names.size()
## Pass 2: rename `.tmp*` → `_dead_.tmp*`. Rename works even on
## AV-locked files (Defender opens without FILE_SHARE_DELETE, but rename
## doesn't need delete share). Any rename failure is non-fatal.
for name in tmp_names:
var src := builds_root.path_join(name)
var dst := builds_root.path_join(DEAD_PREFIX + name)
if dir.rename(src, dst) == OK:
result.renamed += 1
dead_names.append(DEAD_PREFIX + name)
## Pass 3: best-effort recursive delete of every `_dead_*`, including
## ones left over from earlier sweeps that couldn't be cleaned then.
for name in dead_names:
var path := builds_root.path_join(name)
if _remove_recursive(path):
result.deleted += 1
## Final pass: count `_dead_*` survivors so the caller (and tests) can
## see how many genuinely-locked dirs we couldn't reach.
var dir2 := DirAccess.open(builds_root)
if dir2 != null:
dir2.include_hidden = true
dir2.list_dir_begin()
var e := dir2.get_next()
while e != "":
if dir2.current_is_dir() and e.begins_with(DEAD_PREFIX):
result.remaining += 1
e = dir2.get_next()
dir2.list_dir_end()
return result
## Recursive `rm -rf` that swallows access-denied per-file. Returns true
## only when the target directory itself was removed.
static func _remove_recursive(path: String) -> bool:
var dir := DirAccess.open(path)
if dir == null:
## Already gone, or unreadable — try a direct remove just in case
## (an empty dir handle-leak path) and report based on existence.
DirAccess.remove_absolute(path)
return not DirAccess.dir_exists_absolute(path)
dir.include_hidden = true
dir.list_dir_begin()
var entry := dir.get_next()
while entry != "":
if entry == "." or entry == "..":
entry = dir.get_next()
continue
var child := path.path_join(entry)
if dir.current_is_dir():
_remove_recursive(child)
else:
DirAccess.remove_absolute(child)
entry = dir.get_next()
dir.list_dir_end()
## Remove the (hopefully now empty) dir itself. If a hard-linked .pyd is
## still mapped by a surviving process, this fails silently and the
## caller sees `remaining > 0` so it can retry on the next sweep.
DirAccess.remove_absolute(path)
return not DirAccess.dir_exists_absolute(path)
static func _empty_result() -> Dictionary:
return { "scanned": 0, "renamed": 0, "deleted": 0, "remaining": 0 }