Skip to content

Commit 6b6275d

Browse files
Fix uninstall data-delete crash and uv cache-lock hang
Found while exercising `spin uninstall` end-to-end against a real default data dir on this machine. 1. _delete_owned crashed mid-rmtree with FileNotFoundError on studio.sqlite3- shm/-wal: SQLite checkpoints remove WAL sidecar files between rmtree's directory listing and its per-file unlink, aborting the delete and leaving the data dir half-deleted. Now pass an onerror that tolerates FileNotFoundError (already-gone files) but still surfaces real errors like PermissionError, plus a best-effort final sweep. 2. _uv_cleanup hung indefinitely when the uv cache lock was held. The first attempt used subprocess.run(timeout=), which is unreliable here: `uv cache clean` spawns children that keep its pipes open, so run()'s internal communicate() blocked ~94 minutes past a 120 s bound. In particular, `spin uninstall` invoked via `uvx --from ...` runs inside a uv session whose ancestor holds the cache lock, so the child deadlocks on its own parent. Rewrote each step to Popen in its own session with a hard wall-clock deadline that kills the whole process group; on timeout it reports "Timed out ... left as-is" instead of hanging. Verified: real `spin uninstall` now removes the data dir cleanly (no crash, no debris) and completes in bounded time (~241 s worst case, exit 0) with the uv-cleanup steps degrading gracefully. Unit tests added for the WAL race, permission-error propagation, and the wall-clock bound (with a fake hanging uv); no orphaned children leak from the group kill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 80a3ce5 commit 6b6275d

2 files changed

Lines changed: 191 additions & 9 deletions

File tree

‎simpleaudit_studio/lifecycle.py‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import socket
1616
import subprocess
1717
import sys
18+
import time
1819
import uuid
1920
from pathlib import Path
2021

@@ -143,7 +144,22 @@ def _delete_owned(instance: Instance) -> None:
143144
if not safe or data.is_symlink():
144145
raise SystemExit(f"Refusing to delete unexpected data path: {data}")
145146
if data.exists():
146-
shutil.rmtree(data)
147+
# SQLite checkpoints can remove -wal/-shm between rmtree's listing and
148+
# its unlink; tolerate files that are already gone but surface real
149+
# errors (e.g. permission denied) instead of aborting mid-delete.
150+
def _rm_tolerate(fn, path, exc_info):
151+
if not isinstance(exc_info[1], FileNotFoundError):
152+
raise exc_info[1]
153+
154+
shutil.rmtree(data, onerror=_rm_tolerate)
155+
# Best-effort sweep in case a hot journal reappeared as the last step.
156+
for leftover in data.iterdir() if data.exists() else []:
157+
try:
158+
shutil.rmtree(leftover) if leftover.is_dir() else leftover.unlink()
159+
except FileNotFoundError:
160+
pass
161+
if data.exists() and not any(data.iterdir()):
162+
data.rmdir()
147163
if instance.mode == "dev":
148164
for suffix in ("", "-wal", "-shm", "-journal"):
149165
target = Path(str(instance.db) + suffix)
@@ -153,6 +169,34 @@ def _delete_owned(instance: Instance) -> None:
153169
print("Removed instance-owned data. Repository and external results were preserved.")
154170

155171

172+
class _Completed:
173+
"""Minimal stand-in for a finished subprocess result (only returncode used)."""
174+
175+
def __init__(self, returncode: int):
176+
self.returncode = returncode
177+
178+
179+
def _kill_group(proc: subprocess.Popen) -> None:
180+
"""Terminate then kill the whole session/group a child started in."""
181+
try:
182+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
183+
except (ProcessLookupError, PermissionError):
184+
pass
185+
try:
186+
proc.wait(timeout=5)
187+
return
188+
except subprocess.TimeoutExpired:
189+
pass
190+
try:
191+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
192+
except (ProcessLookupError, PermissionError):
193+
pass
194+
try:
195+
proc.wait(timeout=2)
196+
except subprocess.TimeoutExpired:
197+
pass
198+
199+
156200
def _uv_cleanup(mode: str) -> None:
157201
uv = shutil.which("uv")
158202
if not uv:
@@ -161,13 +205,40 @@ def _uv_cleanup(mode: str) -> None:
161205
# Package-scoped cache cleanup, NEVER `uv cache clean` without a name.
162206
# A uvx ephemeral environment may remain: uv offers no reliable per-tool
163207
# selective deletion of all shared cached environments.
208+
#
209+
# Each step is hard-bounded by wall clock. `subprocess.run(timeout=)` is NOT
210+
# sufficient: `uv cache clean` spawns children that keep its pipes open, so
211+
# run()'s internal communicate() can block far past the deadline (observed
212+
# ~94 min vs a 120 s bound). We therefore run each step in its own session
213+
# and kill the whole group on a real deadline.
214+
def _bounded(cmd, timeout):
215+
proc = subprocess.Popen(
216+
cmd,
217+
stdout=subprocess.DEVNULL,
218+
stderr=subprocess.STDOUT,
219+
start_new_session=True,
220+
)
221+
deadline = time.monotonic() + timeout
222+
while True:
223+
rc = proc.poll()
224+
if rc is not None:
225+
return _Completed(rc)
226+
if time.monotonic() >= deadline:
227+
_kill_group(proc)
228+
return None
229+
time.sleep(0.5)
230+
164231
for name in ("simpleaudit-studio", "open-webui"):
165-
result = subprocess.run([uv, "cache", "clean", name], check=False)
166-
if result.returncode:
232+
result = _bounded([uv, "cache", "clean", name], timeout=120)
233+
if result is None:
234+
print(f"Timed out clearing uv cache for {name}; left as-is.")
235+
elif result.returncode:
167236
print(f"Could not clear uv cache for {name} (exit {result.returncode}).")
168237
if mode == "spin":
169-
result = subprocess.run([uv, "tool", "uninstall", "simpleaudit-studio"], check=False, capture_output=True, text=True)
170-
if result.returncode == 0:
238+
result = _bounded([uv, "tool", "uninstall", "simpleaudit-studio"], timeout=60)
239+
if result is None:
240+
print("Timed out removing uv tool installation; left as-is.")
241+
elif result.returncode == 0:
171242
print("Removed permanent uv tool installation.")
172243
else:
173244
print("No permanent uv tool installation removed (uvx does not install one).")

‎tests/test_lifecycle.py‎

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,59 @@ def test_delete_owned_data_only(environment):
7070
assert (checkout / "manage.py").exists()
7171

7272

73+
def test_delete_owned_tolerates_wal_file_vanishing_during_rmtree(environment, monkeypatch):
74+
"""A SQLite checkpoint can remove -wal/-shm after rmtree lists them.
75+
76+
Simulate that by making unlink raise FileNotFoundError for one file; the
77+
delete must still complete rather than abort mid-way and leave debris.
78+
"""
79+
import shutil as _shutil
80+
81+
checkout = environment / "repo"
82+
checkout.mkdir()
83+
instance = lifecycle.Instance("dev", checkout)
84+
data = instance.data
85+
data.mkdir(parents=True)
86+
(data / "studio.sqlite3").write_text("db")
87+
(data / "studio.sqlite3-wal").write_text("wal")
88+
(data / "studio.sqlite3-shm").write_text("shm")
89+
(data / "nested").mkdir()
90+
(data / "nested" / "x.txt").write_text("x")
91+
92+
real_rmtree = _shutil.rmtree
93+
flaky = data / "studio.sqlite3-shm"
94+
95+
def racing_rmtree(path, *args, **kwargs):
96+
# Emulate the checkpoint: drop the shm file right before rmtree walks it.
97+
if Path(path) == data:
98+
try:
99+
flaky.unlink()
100+
except FileNotFoundError:
101+
pass
102+
return real_rmtree(path, *args, **kwargs)
103+
104+
monkeypatch.setattr(lifecycle.shutil, "rmtree", racing_rmtree)
105+
lifecycle._delete_owned(instance)
106+
assert not data.exists()
107+
108+
109+
def test_delete_owned_surfaces_real_errors_not_just_missing_files(environment, monkeypatch):
110+
"""Permission errors must propagate; only FileNotFoundError is tolerated."""
111+
checkout = environment / "repo"
112+
checkout.mkdir()
113+
instance = lifecycle.Instance("dev", checkout)
114+
data = instance.data
115+
data.mkdir(parents=True)
116+
(data / "locked").write_text("x")
117+
118+
def boom(*a, **k):
119+
raise PermissionError(13, "denied", str(data))
120+
121+
monkeypatch.setattr(lifecycle.shutil, "rmtree", boom)
122+
with pytest.raises(PermissionError):
123+
lifecycle._delete_owned(instance)
124+
125+
73126
def test_default_port_selection_has_no_duplicate_ports():
74127
assert len(set(lifecycle._requested_ports(8000).values())) == 4
75128
assert lifecycle._requested_ports(8100)["public"] == 8100
@@ -91,13 +144,71 @@ def test_live_rejects_unowned_pid():
91144
def test_uninstall_only_cleans_named_uv_packages(monkeypatch):
92145
calls = []
93146
monkeypatch.setattr(lifecycle.shutil, "which", lambda _: "/usr/bin/uv")
94-
class Result:
95-
returncode = 0
96-
monkeypatch.setattr(lifecycle.subprocess, "run", lambda args, **kwargs: (calls.append(args), Result())[1])
147+
148+
class DoneProc:
149+
pid = 42
150+
151+
def poll(self):
152+
return 0 # completes immediately
153+
154+
def wait(self, timeout=None):
155+
return None
156+
157+
monkeypatch.setattr(
158+
lifecycle.subprocess,
159+
"Popen",
160+
lambda cmd, **kw: (calls.append(cmd), DoneProc())[1],
161+
)
97162
lifecycle._uv_cleanup("dev")
98163
assert calls == [["/usr/bin/uv", "cache", "clean", "simpleaudit-studio"],
99164
["/usr/bin/uv", "cache", "clean", "open-webui"]]
100-
assert not any(args == ["/usr/bin/uv", "cache", "clean"] for args in calls)
165+
assert not any(c == ["/usr/bin/uv", "cache", "clean"] for c in calls)
166+
167+
168+
def test_uv_cleanup_is_bounded_by_wall_clock_not_run_timeout(monkeypatch, capsys):
169+
"""`uv cache clean` can hold pipes open so subprocess.run(timeout=) blocks far
170+
past its deadline (observed ~94 min vs 120 s). Cleanup must instead enforce a
171+
real wall-clock bound: return quickly and report a timeout when a step hangs."""
172+
import subprocess as _sp
173+
174+
monkeypatch.setattr(lifecycle.shutil, "which", lambda _: "/usr/bin/uv")
175+
176+
class HangingProc:
177+
pid = 999999
178+
179+
def poll(self):
180+
return None # never finishes
181+
182+
def wait(self, timeout=None):
183+
raise _sp.TimeoutExpired("cmd", timeout)
184+
185+
killed = []
186+
187+
def fake_popen(cmd, **kwargs):
188+
assert kwargs.get("start_new_session") is True
189+
return HangingProc()
190+
191+
def fake_kill_group(proc):
192+
killed.append(proc)
193+
194+
monkeypatch.setattr(lifecycle.subprocess, "Popen", fake_popen)
195+
monkeypatch.setattr(lifecycle, "_kill_group", fake_kill_group)
196+
197+
# Fast-forward the clock so each step's deadline elapses after one iteration,
198+
# while still proving the loop terminates (vs the old 94-minute hang).
199+
clock = {"now": 0.0}
200+
201+
def fake_monotonic():
202+
clock["now"] += 1000.0 # jump past any deadline instantly
203+
return clock["now"]
204+
205+
monkeypatch.setattr(lifecycle.time, "monotonic", fake_monotonic)
206+
monkeypatch.setattr(lifecycle.time, "sleep", lambda *_: None)
207+
lifecycle._uv_cleanup("spin")
208+
assert killed, "hung steps must be killed via the group killer"
209+
out = capsys.readouterr().out
210+
assert "Timed out clearing uv cache" in out
211+
assert "Timed out removing uv tool installation" in out
101212

102213

103214
def test_uninstall_cannot_use_live_external_db(environment, monkeypatch):

0 commit comments

Comments
 (0)