-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathuv_cache_cleanup.gd
More file actions
161 lines (146 loc) · 6.3 KB
/
Copy pathuv_cache_cleanup.gd
File metadata and controls
161 lines (146 loc) · 6.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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 }