Summary
tests/test_config_unreadable_rewrite.py::test_eperm_on_both_names_falls_back_too (added in #2356, f3f23d1) is the only permission-injection test in that file without the @needs_unprivileged_posix guard. Its ten siblings have it, including its immediate neighbour test_erofs_on_both_names_falls_back_rather_than_raising, which is the same shape with a different errno.
On Windows the unguarded test therefore runs, and it does not fail — it becomes CPU-bound and does not terminate. pytest on the full suite never finishes. I left one orphaned run going and it accumulated 13,527 seconds of CPU without converging.
Why it spins
The test monkeypatches os.open to raise OSError(EPERM) for every basename starting with config.json. or .config.json.. MempalaceConfig.set_backend falls back to:
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
so every candidate name mkstemp generates matches the patch. CPython's tempfile._mkstemp_inner has a Windows-only branch that reads PermissionError as "a directory of that name already exists" and continues the loop instead of raising:
except PermissionError:
# This exception is thrown when a directory with the chosen name
# already exists on windows.
if (_os.name == 'nt' and _os.path.isdir(dir) and _os.access(dir, _os.W_OK)):
continue
else:
raise
The loop is for seq in range(TMP_MAX), and on this build tempfile.TMP_MAX is 2147483647.
Reproduction (no MemPalace involved)
import errno, os, sys, tempfile, threading, time
print(f"python={sys.version.split()[0]} os.name={os.name} TMP_MAX={tempfile.TMP_MAX}", flush=True)
d = tempfile.mkdtemp()
real_open = os.open
attempts = 0
def deny(path, *args, **kwargs):
global attempts
if os.path.basename(str(path)).startswith(".probe."):
attempts += 1
raise OSError(errno.EPERM, "Operation not permitted")
return real_open(path, *args, **kwargs)
os.open = deny
done = threading.Event()
def run():
try:
tempfile.mkstemp(dir=d, prefix=".probe.", suffix=".tmp")
except BaseException as exc:
print(f"raised {type(exc).__name__}: {exc}", flush=True)
done.set()
threading.Thread(target=run, daemon=True).start()
if not done.wait(timeout=20):
print(f"still spinning after 20s; os.open called {attempts} times", flush=True)
os._exit(0)
Output here:
python=3.12.14 os.name=nt TMP_MAX=2147483647
still spinning after 20s; os.open called 175954 times
At ~8,800 attempts/second, 2,147,483,647 attempts is roughly 68 hours. It is not an infinite loop in the strict sense, but for a test run it may as well be.
Environment
Suggested fix
Add @needs_unprivileged_posix to test_eperm_on_both_names_falls_back_too, matching its EROFS sibling. The guard already reads os.name == "nt" or geteuid() == 0, so this only restores the intent that the rest of the file already follows.
Separately, it may be worth bounding the mkstemp fallback in config.py (the retry count is the stdlib's, not yours, but the prefix collision is what routes every attempt into it).
Workaround for anyone running the suite on Windows today
pytest --ignore=tests/test_config_unreadable_rewrite.py
With that ignore the suite completes in 6m16s here (4226 passed, 364 skipped).
Summary
tests/test_config_unreadable_rewrite.py::test_eperm_on_both_names_falls_back_too(added in #2356, f3f23d1) is the only permission-injection test in that file without the@needs_unprivileged_posixguard. Its ten siblings have it, including its immediate neighbourtest_erofs_on_both_names_falls_back_rather_than_raising, which is the same shape with a different errno.On Windows the unguarded test therefore runs, and it does not fail — it becomes CPU-bound and does not terminate.
pyteston the full suite never finishes. I left one orphaned run going and it accumulated 13,527 seconds of CPU without converging.Why it spins
The test monkeypatches
os.opento raiseOSError(EPERM)for every basename starting withconfig.json.or.config.json..MempalaceConfig.set_backendfalls back to:so every candidate name mkstemp generates matches the patch. CPython's
tempfile._mkstemp_innerhas a Windows-only branch that readsPermissionErroras "a directory of that name already exists" and continues the loop instead of raising:The loop is
for seq in range(TMP_MAX), and on this buildtempfile.TMP_MAXis2147483647.Reproduction (no MemPalace involved)
Output here:
At ~8,800 attempts/second, 2,147,483,647 attempts is roughly 68 hours. It is not an infinite loop in the strict sense, but for a test run it may as well be.
Environment
tempfile.TMP_MAX = 2147483647origin/develop@ a9f345c (file present since f3f23d1 / mempalace init --backend overwrites a config.json it could not read, losing every setting in it #2356)Suggested fix
Add
@needs_unprivileged_posixtotest_eperm_on_both_names_falls_back_too, matching its EROFS sibling. The guard already readsos.name == "nt" or geteuid() == 0, so this only restores the intent that the rest of the file already follows.Separately, it may be worth bounding the mkstemp fallback in
config.py(the retry count is the stdlib's, not yours, but the prefix collision is what routes every attempt into it).Workaround for anyone running the suite on Windows today
With that ignore the suite completes in 6m16s here (4226 passed, 364 skipped).