mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
feat(temp): give each download task its own temp directory
Each dl run works in temp/task_<id>/, removed on every exit path. Orphans from hard-killed processes are swept via lock liveness, and env clear temp skips directories whose task is still running.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""Tests for per-task temp directories.
|
||||
|
||||
Each run gets a private dir that is removed on every exit path. The sweep reclaims
|
||||
dirs from hard-killed processes via lock liveness, and `env clear temp` skips the
|
||||
dir of a task that is still running.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from filelock import FileLock
|
||||
|
||||
from unshackle.commands.env import clear_directory
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.temp import LOCK_NAME, TASK_PREFIX, is_stale, sweep_task_dirs, task_temp_dir
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
root = tmp_path / "temp"
|
||||
root.mkdir()
|
||||
monkeypatch.setattr(config.directories, "temp", root)
|
||||
return root
|
||||
|
||||
|
||||
def backdate(path: Path) -> None:
|
||||
stamp = time.time() - 120
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def make_task_dir(root: Path, name: str = "task_deadbeef", lock: bool = True) -> Path:
|
||||
task_dir = root / name
|
||||
task_dir.mkdir()
|
||||
if lock:
|
||||
(task_dir / LOCK_NAME).touch()
|
||||
return task_dir
|
||||
|
||||
|
||||
def test_task_dir_created_repointed_and_restored(temp_root: Path) -> None:
|
||||
with task_temp_dir() as task_dir:
|
||||
assert task_dir.parent == temp_root
|
||||
assert task_dir.name.startswith(TASK_PREFIX)
|
||||
assert config.directories.temp == task_dir
|
||||
assert config.directories.temp == temp_root
|
||||
assert not task_dir.exists()
|
||||
|
||||
|
||||
def test_task_dir_removed_on_exception(temp_root: Path) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
with task_temp_dir() as task_dir:
|
||||
(task_dir / "partial.mp4").write_bytes(b"x")
|
||||
raise ValueError("boom")
|
||||
assert not task_dir.exists()
|
||||
assert config.directories.temp == temp_root
|
||||
|
||||
|
||||
def test_task_dir_removed_on_keyboard_interrupt(temp_root: Path) -> None:
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
with task_temp_dir() as task_dir:
|
||||
raise KeyboardInterrupt
|
||||
assert not task_dir.exists()
|
||||
assert config.directories.temp == temp_root
|
||||
|
||||
|
||||
def test_sweep_removes_unlocked_stale_dir(temp_root: Path) -> None:
|
||||
task_dir = make_task_dir(temp_root)
|
||||
(task_dir / "leftover.mp4").write_bytes(b"x")
|
||||
backdate(task_dir)
|
||||
sweep_task_dirs(temp_root)
|
||||
assert not task_dir.exists()
|
||||
|
||||
|
||||
def test_sweep_skips_locked_dir(temp_root: Path) -> None:
|
||||
task_dir = make_task_dir(temp_root)
|
||||
lock = FileLock(task_dir / LOCK_NAME)
|
||||
lock.acquire()
|
||||
backdate(task_dir)
|
||||
try:
|
||||
assert is_stale(task_dir) is False
|
||||
sweep_task_dirs(temp_root)
|
||||
assert task_dir.exists()
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def test_sweep_skips_young_unlocked_dir(temp_root: Path) -> None:
|
||||
task_dir = make_task_dir(temp_root)
|
||||
assert is_stale(task_dir) is False
|
||||
sweep_task_dirs(temp_root)
|
||||
assert task_dir.exists()
|
||||
|
||||
|
||||
def test_sweep_removes_stale_dir_without_lock_file(temp_root: Path) -> None:
|
||||
task_dir = make_task_dir(temp_root, lock=False)
|
||||
backdate(task_dir)
|
||||
assert is_stale(task_dir) is True
|
||||
sweep_task_dirs(temp_root)
|
||||
assert not task_dir.exists()
|
||||
|
||||
|
||||
def test_sweep_ignores_non_task_entries(temp_root: Path) -> None:
|
||||
loose_file = temp_root / "leftover.mp4"
|
||||
loose_file.write_bytes(b"x")
|
||||
other_dir = temp_root / "not_a_task"
|
||||
other_dir.mkdir()
|
||||
backdate(loose_file)
|
||||
backdate(other_dir)
|
||||
sweep_task_dirs(temp_root)
|
||||
assert loose_file.exists()
|
||||
assert other_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink creation needs privileges on Windows")
|
||||
def test_sweep_never_follows_symlinked_task_dir(temp_root: Path, tmp_path: Path) -> None:
|
||||
target = tmp_path / "victim"
|
||||
target.mkdir()
|
||||
(target / "precious.mkv").write_bytes(b"data")
|
||||
link = temp_root / "task_evil"
|
||||
link.symlink_to(target)
|
||||
backdate(target)
|
||||
sweep_task_dirs(temp_root)
|
||||
assert (target / "precious.mkv").exists()
|
||||
assert not (target / LOCK_NAME).exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink creation needs privileges on Windows")
|
||||
def test_clear_directory_unlinks_symlinked_dir_without_following(temp_root: Path, tmp_path: Path) -> None:
|
||||
target = tmp_path / "victim"
|
||||
target.mkdir()
|
||||
(target / "precious.mkv").write_bytes(b"data")
|
||||
link = temp_root / "task_evil"
|
||||
link.symlink_to(target)
|
||||
backdate(target)
|
||||
files_count, _ = clear_directory(temp_root)
|
||||
assert not link.exists()
|
||||
assert (target / "precious.mkv").exists()
|
||||
assert files_count == 1
|
||||
|
||||
|
||||
def test_clear_directory_skips_locked_task_dir(temp_root: Path) -> None:
|
||||
task_dir = make_task_dir(temp_root)
|
||||
(task_dir / "in_flight.mp4").write_bytes(b"xyz")
|
||||
lock = FileLock(task_dir / LOCK_NAME)
|
||||
lock.acquire()
|
||||
backdate(task_dir)
|
||||
loose_file = temp_root / "leftover.mp4"
|
||||
loose_file.write_bytes(b"1234")
|
||||
try:
|
||||
files_count, freed_bytes = clear_directory(temp_root)
|
||||
finally:
|
||||
lock.release()
|
||||
assert task_dir.exists()
|
||||
assert (task_dir / "in_flight.mp4").exists()
|
||||
assert not loose_file.exists()
|
||||
assert files_count == 1
|
||||
assert freed_bytes == 4
|
||||
|
||||
|
||||
def test_clear_directory_removes_stale_task_dir(temp_root: Path) -> None:
|
||||
task_dir = make_task_dir(temp_root)
|
||||
(task_dir / "orphan.mp4").write_bytes(b"12345")
|
||||
backdate(task_dir)
|
||||
files_count, freed_bytes = clear_directory(temp_root)
|
||||
assert not task_dir.exists()
|
||||
assert temp_root.is_dir()
|
||||
# glob("**/*") never matches dotfiles, so .lock is not counted
|
||||
assert files_count == 1
|
||||
assert freed_bytes == 5
|
||||
Reference in New Issue
Block a user