Skip to content

Commit d547308

Browse files
committed
The sandbox sweep reads its prefixes out of the test sources
1 parent 21b58a8 commit d547308

6 files changed

Lines changed: 541 additions & 27 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ jobs:
230230
tests/test_projects_manifest.py \
231231
tests/test_public_export.py tests/test_public_scan.py \
232232
tests/test_release_artifact.py tests/support.py \
233+
tests/test_sandbox_sweep.py \
233234
tests/shard.py \
234235
tests/test_readme_art.py tests/test_readme_keys.py \
235236
tools/readme_art_lib.py tools/readme_keys.py

‎SOURCE.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"exported_files": 314,
2+
"exported_files": 315,
33
"schema_version": 1,
4-
"source_commit": "299bcac6bd83c71ec515275e8af398af9ef8a4eb"
4+
"source_commit": "17c13b2f9b16d0df770a4d01a64620b25686ff69"
55
}

‎public-files.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ tests/test_release_artifact.py
139139
tests/test_release_gc.py
140140
tests/test_restore_identity.py
141141
tests/test_sandbox_guard.py
142+
tests/test_sandbox_sweep.py
142143
tests/test_session_colors.py
143144
tests/test_snapshot_contract.py
144145
tests/test_state_column.py

‎tests/support.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@ def source_tree_ignore(root: Path = REPO):
4141
test removes one and copytree lists an entry, then fails to open it, and the
4242
run dies over a file that was never part of the source.
4343
44-
c9d85e9 fixed that in one of the three by listing sweep_sandboxes.PREFIXES.
45-
That list holds six prefixes; the suite builds a hundred and eighty-three
46-
inside the checkout, so the fix covered neither of the two sandboxes that
47-
have actually broken a run. Prefixes were never the right shape here.
44+
c9d85e9 fixed that in one of the three by borrowing the sweep's list of
45+
sandbox prefixes. Six were named against the two hundred and more the suite
46+
hands tempfile, so the fix covered neither of the two sandboxes that have
47+
actually broken a run. Prefixes were never the right shape here.
48+
49+
tests/sweep_sandboxes.py no longer carries a list -- it reads the prefixes
50+
out of the test sources -- and this rule still does not borrow from it. A
51+
copy rule and a delete rule disagree about `.git`: the copy must drop it,
52+
the sweep must protect it. Two answers, two constants, no shared edit.
4853
4954
.gitignore settled this for Git already -- `/.*/` with `!/.github/`, under
5055
the note "Ignore the whole class rather than chase prefixes" -- so this

‎tests/sweep_sandboxes.py‎

Lines changed: 208 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,44 +15,221 @@
1515
the grace period is touched, so a run in flight -- including a parallel one --
1616
is never disturbed.
1717
18+
Which names count as a sandbox is READ FROM THE SUITE, never listed here. The
19+
hand-written list this file used to carry named six of the two hundred and
20+
more the suite hands tempfile, so most of what a crashed run leaves stayed
21+
where it was -- including the leaked processes this exists to kill, because it
22+
decides those by sandbox too. A list somebody has to remember to extend is a
23+
list that is wrong from the next commit onward. `sandbox_prefixes()` parses the
24+
test sources instead, and `tests/test_sandbox_sweep.py` fails when a fixture
25+
writes a prefix that parse cannot read.
26+
1827
Run for its effect, from tests/run. It prints only what it actually removed.
28+
`--list` prints the same judgements and removes nothing.
1929
"""
2030

2131
from __future__ import annotations
2232

33+
import ast
2334
import os
2435
from pathlib import Path
2536
import shutil
2637
import signal
38+
import string
2739
import sys
2840
import time
2941

3042
REPO = Path(__file__).resolve().parents[1]
31-
# Prefixes the fixtures use for their sandboxes, all of them dotted so
32-
# .gitignore's `/.*/` already keeps them out of a commit.
33-
PREFIXES = (
34-
".login-",
35-
".commands-",
36-
".msg-cli-",
37-
".account-",
38-
".projects-",
39-
# tests/sandbox_guard.py builds one per run and removes it at interpreter
40-
# exit; a SIGKILLed run leaves it behind like any other sandbox.
41-
".sandbox-guard-",
42-
)
43+
TESTS = REPO / "tests"
44+
45+
# The two calls that make a DIRECTORY under a name tempfile chooses. mkstemp
46+
# and NamedTemporaryFile are deliberately absent: they leave a file, and this
47+
# sweep only ever removes directories.
48+
SANDBOX_CALLS = frozenset({"TemporaryDirectory", "mkdtemp"})
49+
50+
# The characters tempfile draws the random part of a name from
51+
# (tempfile._RandomNameSequence.characters). A leftover sandbox is a known
52+
# prefix followed by a run of these and nothing else, which is what separates
53+
# `.watchdog-g2hiuui4` from a directory somebody deliberately named
54+
# `.watchdog-notes`. Note the absence of `-`: it keeps a short prefix from
55+
# claiming a longer sibling, so `.reap-` cannot answer for `.reap-er-x1y2`.
56+
RANDOM_NAME_CHARACTERS = frozenset(string.ascii_lowercase + string.digits + "_")
57+
58+
# Never removed, whatever the derived prefixes say, and checked before any
59+
# prefix is consulted.
60+
#
61+
# `.git` is first because it is the only entry here whose loss cannot be
62+
# undone: every commit not yet pushed lives inside it and nothing in the
63+
# working tree can rebuild it. The other three are the repository's own dotted
64+
# entries at the root -- source, not litter -- and the same argument applies to
65+
# each: a mistake about them is not litter left behind, it is source removed.
66+
# No prefix in the tree reaches any of the four today; this is what holds if
67+
# one ever does, and tests/test_sandbox_sweep.py proves it by feeding the
68+
# matcher a prefix that would swallow `.git`.
69+
#
70+
# This is NOT `tests.support.SOURCE_ROOT_DOTTED` and must never be replaced by
71+
# it. That set answers a different question -- what a COPY of this tree must
72+
# leave behind -- and it deliberately omits `.git`, because a copy carrying
73+
# history is the bug it was written to stop. Two rules with opposite
74+
# requirements for the same name; sharing one constant between them puts a
75+
# deleting rule one edit away from losing the repository.
76+
NEVER_REMOVE = frozenset({".git", ".github", ".gitignore", ".shellcheckrc"})
77+
4378
# Long enough that no live run is ever in range, short enough that a leak costs
4479
# one run rather than a week. The override exists so this file can be tested
4580
# against a sandbox created a moment ago.
4681
GRACE_SECONDS = int(os.environ.get("SESSION_KIT_SWEEP_GRACE_SECONDS") or 2 * 3600)
4782

4883

49-
def stale_sandboxes() -> list[Path]:
84+
def called_name(node: ast.Call) -> str:
85+
"""`tempfile.mkdtemp(...)` and `mkdtemp(...)` both answer `mkdtemp`."""
86+
function = node.func
87+
if isinstance(function, ast.Attribute):
88+
return function.attr
89+
if isinstance(function, ast.Name):
90+
return function.id
91+
return ""
92+
93+
94+
def string_literal(expression: ast.expr | None) -> str | None:
95+
if isinstance(expression, ast.Constant) and isinstance(expression.value, str):
96+
return expression.value
97+
return None
98+
99+
100+
def string_argument(node: ast.Call, keyword: str, position: int) -> str | None:
101+
"""The literal a call passes for `keyword`, by name or at `position`."""
102+
for entry in node.keywords:
103+
if entry.arg == keyword:
104+
return string_literal(entry.value)
105+
if entry.arg is None:
106+
# `**kwargs` could carry the prefix; nothing here can read it.
107+
return None
108+
if position < len(node.args):
109+
return string_literal(node.args[position])
110+
return None
111+
112+
113+
def parse_test_sources() -> dict[Path, ast.Module]:
114+
"""Every test module that parses, by path.
115+
116+
A file that does not parse is skipped and said out loud rather than taken
117+
as having no sandboxes. Skipping narrows the sweep, which leaves litter;
118+
the alternative reading -- treat an unparseable file as empty and carry on
119+
silently -- is how the previous list went stale unnoticed.
120+
"""
121+
trees: dict[Path, ast.Module] = {}
122+
if not TESTS.is_dir():
123+
print(f"tests: no {TESTS}, sweeping nothing", file=sys.stderr)
124+
return trees
125+
for path in sorted(TESTS.rglob("*.py")):
126+
try:
127+
source = path.read_text(encoding="utf-8")
128+
trees[path] = ast.parse(source, filename=str(path))
129+
except (OSError, SyntaxError, UnicodeDecodeError) as error:
130+
print(
131+
f"tests: cannot read sandbox names from {path}: {error}",
132+
file=sys.stderr,
133+
)
134+
return trees
135+
136+
137+
def sandbox_factories(trees: dict[Path, ast.Module]) -> dict[str, tuple[str, int]]:
138+
"""Helpers that forward a caller's string straight to tempfile.
139+
140+
`tests/test_worktree_isolation.sandbox(prefix)` is the shape: one function
141+
the whole file goes through, so the literals sit at its call sites instead
142+
of at the tempfile call. Maps the helper's name to the name and position of
143+
the parameter it forwards, so either spelling of a call site can be read.
144+
Names are collected across the whole tree because helpers are imported
145+
between modules.
146+
"""
147+
factories: dict[str, tuple[str, int]] = {}
148+
for tree in trees.values():
149+
for node in ast.walk(tree):
150+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
151+
continue
152+
parameters = [argument.arg for argument in node.args.args]
153+
for inner in ast.walk(node):
154+
if not isinstance(inner, ast.Call):
155+
continue
156+
if called_name(inner) not in SANDBOX_CALLS:
157+
continue
158+
for entry in inner.keywords:
159+
if entry.arg != "prefix":
160+
continue
161+
forwarded = entry.value
162+
if isinstance(forwarded, ast.Name) and forwarded.id in parameters:
163+
factories[node.name] = (
164+
forwarded.id,
165+
parameters.index(forwarded.id),
166+
)
167+
return factories
168+
169+
170+
def sandbox_prefixes() -> frozenset[str]:
171+
"""Every prefix the suite can hand tempfile, read from the test sources.
172+
173+
Call sites are taken whatever their `dir=` argument says. Where a fixture
174+
MEANT to put its sandbox is not a property this file can evaluate -- the
175+
argument is an expression, and several are read from the environment -- and
176+
guessing at it is what a sweep must not do. What makes a removal safe is
177+
the name: a prefix from this set followed by tempfile's random characters
178+
and nothing else, at the repository root, outside `NEVER_REMOVE`. A prefix
179+
belonging to a sandbox that normally lands in /tmp costs nothing here,
180+
because no such directory appears at the root; a fixture that moves its
181+
sandbox INTO the checkout is covered from the same commit that moves it.
182+
"""
183+
trees = parse_test_sources()
184+
factories = sandbox_factories(trees)
185+
found: set[str] = set()
186+
for tree in trees.values():
187+
for node in ast.walk(tree):
188+
if not isinstance(node, ast.Call):
189+
continue
190+
name = called_name(node)
191+
if name in SANDBOX_CALLS:
192+
# tempfile takes prefix second, after suffix.
193+
prefix = string_argument(node, "prefix", 1)
194+
elif name in factories:
195+
keyword, position = factories[name]
196+
prefix = string_argument(node, keyword, position)
197+
else:
198+
continue
199+
if isinstance(prefix, str) and prefix:
200+
found.add(prefix)
201+
return frozenset(found)
202+
203+
204+
def is_a_sandbox_name(name: str, prefixes: frozenset[str]) -> bool:
205+
"""Could the suite have created a directory called this?
206+
207+
Protected names are refused before any prefix is looked at, so widening the
208+
prefix set can never reach one.
209+
"""
210+
if name in NEVER_REMOVE:
211+
return False
212+
for prefix in prefixes:
213+
if not name.startswith(prefix):
214+
continue
215+
random_part = name[len(prefix) :]
216+
if random_part and set(random_part) <= RANDOM_NAME_CHARACTERS:
217+
return True
218+
return False
219+
220+
221+
def stale_sandboxes(prefixes: frozenset[str]) -> list[Path]:
50222
cutoff = time.time() - GRACE_SECONDS
51223
found = []
52224
for entry in REPO.iterdir():
53225
if not entry.is_dir() or entry.is_symlink():
54226
continue
55-
if not entry.name.startswith(PREFIXES):
227+
if not is_a_sandbox_name(entry.name, prefixes):
228+
continue
229+
# Nothing outside the checkout, ever. iterdir yields only children of
230+
# REPO and the symlinks are already gone, so this cannot point
231+
# elsewhere; it is checked anyway because rmtree is what comes next.
232+
if entry.resolve().parent != REPO:
56233
continue
57234
try:
58235
if entry.stat().st_mtime > cutoff:
@@ -63,7 +240,7 @@ def stale_sandboxes() -> list[Path]:
63240
return sorted(found)
64241

65242

66-
def in_a_sandbox(cwd: str) -> bool:
243+
def in_a_sandbox(cwd: str, prefixes: frozenset[str]) -> bool:
67244
"""Is this working directory inside a fixture sandbox, live or deleted?
68245
69246
The kernel appends " (deleted)" once the directory is gone, and gone is the
@@ -78,7 +255,7 @@ def in_a_sandbox(cwd: str) -> bool:
78255
except ValueError:
79256
return False
80257
head = relative.parts[0] if relative.parts else ""
81-
return head.startswith(PREFIXES)
258+
return is_a_sandbox_name(head, prefixes)
82259

83260

84261
def process_age_seconds(pid: str) -> float | None:
@@ -106,7 +283,9 @@ def process_age_seconds(pid: str) -> float | None:
106283
return max(0.0, uptime - started_ticks / ticks)
107284

108285

109-
def leaked_processes(stale: list[Path]) -> list[tuple[int, str]]:
286+
def leaked_processes(
287+
stale: list[Path], prefixes: frozenset[str]
288+
) -> list[tuple[int, str]]:
110289
"""Same-user pids that cannot belong to a live run, inside a sandbox.
111290
112291
Two ways to be sure. A process sitting in a sandbox this sweep has already
@@ -127,7 +306,7 @@ def leaked_processes(stale: list[Path]) -> list[tuple[int, str]]:
127306
cwd = os.readlink(f"{base}/cwd")
128307
except OSError:
129308
continue
130-
if not in_a_sandbox(cwd):
309+
if not in_a_sandbox(cwd, prefixes):
131310
continue
132311
plain = cwd[: -len(" (deleted)")] if cwd.endswith(" (deleted)") else cwd
133312
inside_stale = any(
@@ -148,11 +327,19 @@ def leaked_processes(stale: list[Path]) -> list[tuple[int, str]]:
148327
return out
149328

150329

151-
def main() -> int:
152-
roots = stale_sandboxes()
153-
leaked = leaked_processes(roots)
330+
def main(argv: list[str] | None = None) -> int:
331+
listing = "--list" in (argv if argv is not None else sys.argv[1:])
332+
prefixes = sandbox_prefixes()
333+
roots = stale_sandboxes(prefixes)
334+
leaked = leaked_processes(roots, prefixes)
154335
if not roots and not leaked:
155336
return 0
337+
if listing:
338+
for pid, command in leaked:
339+
print(f"tests: would end leaked process {pid} ({command[:60]})")
340+
for root in roots:
341+
print(f"tests: would remove stale sandbox {root.name}")
342+
return 0
156343
for pid, command in leaked:
157344
# The process group: a provider CLI runs under `script`, and killing the
158345
# wrapper alone leaves the CLI holding its conversation.

0 commit comments

Comments
 (0)