test(browser): keep the scratch on disk when /dev/shm is short, delete in place after cleanup (#384)

The largest scratch copy is about 8 MB; Docker's default /dev/shm is 64 MB
and shared with the browsers. A reset that empties a folder after the
server's cleanup closed its trash deletes in place instead of starting a
thread nobody stops.
This commit is contained in:
Vito Sansevero
2026-10-27 07:21:10 -07:00
parent f707a1abe5
commit d7893b6e8e
4 changed files with 52 additions and 4 deletions
+11 -1
View File
@@ -36,6 +36,11 @@ SEED_ON_LIVE_ENV = "PM_TEST_SEED_ON_LIVE"
SCRATCH_SCHEMA = "reset_seed"
# Linux's shared-memory tmpfs (present in the Playwright image and on runners).
MEMORY_ROOT = Path("/dev/shm")
# The largest fixtures' scratch copy is about 8 MB (database plus a WAL that
# autocheckpoints near 4 MB). Docker's default /dev/shm is 64 MB, shared with
# the browsers and the other workers; with less free than this, the scratch
# copy goes on disk instead.
MEMORY_FREE_BYTES = 32 * 1024 * 1024
def _quote(name: str) -> str:
@@ -225,7 +230,12 @@ class ScratchSeeder:
kernel is writing back the pages they rewrite.
"""
root = self._memory_root
if root is not None and root.is_dir() and os.access(root, os.W_OK):
if (
root is not None
and root.is_dir()
and os.access(root, os.W_OK)
and shutil.disk_usage(root).free >= MEMORY_FREE_BYTES
):
return Path(tempfile.mkdtemp(prefix="pm-reset-", dir=root))
self._fallback.mkdir(parents=True, exist_ok=True)
return self._fallback
+11 -3
View File
@@ -46,6 +46,9 @@ class _Trash:
_trashes: dict[Path, _Trash] = {}
# Trash folders whose server has cleaned up: a reset still running then
# deletes in place instead of starting a thread nobody would stop.
_closed: set[Path] = set()
_lock = threading.Lock()
@@ -56,15 +59,20 @@ def replace_folder(folder: Path) -> None:
directory = folder.parent / TRASH_NAME
with _lock:
trash = _trashes.get(directory)
if trash is None:
if trash is None and directory not in _closed:
trash = _trashes[directory] = _Trash(directory)
trash.discard(folder)
if trash is not None:
trash.discard(folder)
if folder.exists():
shutil.rmtree(folder)
folder.mkdir()
def close_trash(parent: Path) -> None:
"""Stop deleting under ``parent``'s trash and remove it (blocking)."""
directory = Path(parent) / TRASH_NAME
with _lock:
trash = _trashes.pop(Path(parent) / TRASH_NAME, None)
_closed.add(directory)
trash = _trashes.pop(directory, None)
if trash is not None:
trash.close()
+15
View File
@@ -255,6 +255,21 @@ class TestScratchSeeder(unittest.TestCase):
seeder.close()
self.assertEqual(list(memory.iterdir()), [])
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
memory = self.root / "memory"
memory.mkdir()
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
self.addCleanup(seeder.close)
full = mock.patch.object(
fast_reset.shutil,
"disk_usage",
return_value=types.SimpleNamespace(free=fast_reset.MEMORY_FREE_BYTES - 1),
)
with full:
seeder.run(reseed)
self.assertEqual(list(memory.iterdir()), [])
self.assertTrue((self.root / "fallback" / "seed.db").exists())
def test_close_releases_the_scratch_files(self):
self.seeder.run(reseed)
self.seeder.close()
+15
View File
@@ -48,5 +48,20 @@ class TestFolderTrash(unittest.TestCase):
close_trash(self.root) # idempotent
class TestFolderTrashAfterClose(unittest.TestCase):
def test_a_replacement_after_close_deletes_in_place_without_a_thread(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
close_trash(root)
folder = root / "output"
folder.mkdir()
(folder / "late.png").write_bytes(b"png")
replace_folder(folder)
self.assertEqual(list(folder.iterdir()), [])
self.assertFalse((root / TRASH_NAME).exists())
names = [t.name for t in threading.enumerate()]
self.assertNotIn("pm-reset-trash", names)
if __name__ == "__main__":
unittest.main()