1515the grace period is touched, so a run in flight -- including a parallel one --
1616is 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+
1827Run for its effect, from tests/run. It prints only what it actually removed.
28+ `--list` prints the same judgements and removes nothing.
1929"""
2030
2131from __future__ import annotations
2232
33+ import ast
2334import os
2435from pathlib import Path
2536import shutil
2637import signal
38+ import string
2739import sys
2840import time
2941
3042REPO = 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.
4681GRACE_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
84261def 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