Torture harness: check for leaked file descriptors on macOS too

check_resources() listed /proc/self/fd, which only exists on Linux, so on
macOS every scenario crashed at the end with FileNotFoundError. macOS now
lists /dev/fd and resolves each descriptor with fcntl F_GETPATH, which,
like /proc, still reports files that have been deleted (psutil's
open_files() leaves those out). The temp directory is compared via
realpath(), since macOS reports /var/folders as /private/var/folders.

The test now fails with the harness's stderr when it exits without a
summary line, instead of an IndexError that hid the real traceback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2026-10-06 15:08:20 -07:00
commit 2bf79d0411
2 changed files with 37 additions and 9 deletions

View file

@ -295,6 +295,29 @@ class Hang(Exception):
pass
def open_file_paths():
# Paths of every file this process has open, including files that have
# since been deleted (psutil's open_files() leaves those out)
paths = []
if os.path.isdir("/proc/self/fd"):
for fd in os.listdir("/proc/self/fd"):
try:
paths.append(os.readlink(f"/proc/self/fd/{fd}"))
except OSError:
continue
return paths
# macOS: no /proc, but F_GETPATH works on any open descriptor
import fcntl
for fd in os.listdir("/dev/fd"):
try:
path = fcntl.fcntl(int(fd), fcntl.F_GETPATH, bytes(1024))
except OSError:
continue
paths.append(os.fsdecode(path.rstrip(b"\0")))
return paths
def make_file(path, wal=False, extra_sql=""):
conn = sqlite3.connect(path)
if wal:
@ -1190,16 +1213,15 @@ class Torture:
def check_resources(self, baseline_threads):
deadline = time.monotonic() + 15
# On macOS the temp directory is reported via its /private symlink target
tmp = os.path.realpath(self.tmp)
while True:
gc.collect()
leaked_fds = []
for fd in os.listdir("/proc/self/fd"):
try:
target = os.readlink(f"/proc/self/fd/{fd}")
except OSError:
continue
if target.startswith(self.tmp) or "datasette_temp_" in target:
leaked_fds.append(target)
leaked_fds = [
target
for target in open_file_paths()
if target.startswith(tmp) or "datasette_temp_" in target
]
threads = [
t.name
for t in threading.enumerate()

View file

@ -58,7 +58,13 @@ def test_connection_torture(scenario, seed):
f"{scenario} crashed with {signal.Signals(-proc.returncode).name}:\n"
f"{proc.stderr[-4000:]}"
)
summary = json.loads(proc.stdout.strip().splitlines()[-1])
lines = proc.stdout.strip().splitlines()
if not lines:
pytest.fail(
f"{scenario} exited {proc.returncode} without a summary:\n"
f"{proc.stderr[-4000:]}"
)
summary = json.loads(lines[-1])
problems = "\n".join(
f"[{p['kind']}] {p['message'][:600]}" for p in summary["problems"]
)