Replace dasher-pack with unified animation-pack using original Blender bone names
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
@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 }
|
||||
Reference in New Issue
Block a user