From b35b6a36899059fba89d344c46217434c010ea8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 10:34:08 +0100 Subject: [PATCH] Clear the stranded tmpfs cache at startup The session map is memory-only, so session directories surviving a restart can never be served and never be evicted -- and the work root is a tmpfs, so that is leaked RAM until reboot. The restart that ships the TTFB fix would have stranded 1.56 GB. Co-Authored-By: Claude Opus 5 --- plan.md | 9 +++++++++ proxy/ytstream_proxy.py | 26 ++++++++++++++++++++++++++ tests/test_proxy.py | 26 ++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/plan.md b/plan.md index 0e30f6a..5918f6f 100644 --- a/plan.md +++ b/plan.md @@ -1137,3 +1137,12 @@ fast mux keeping ranges and seeking, `--first-byte-grace` at `--wait-timeout` restoring strict mode, a failed producer with bytes being served but 502 in strict mode, a failed producer with no bytes always 502, `--growing` meaning no wait, and `/healthz` reporting the mode. 353 pass. + +### A restart used to strand the cache + +The session map is in memory only, so every session directory left in the work root +after a restart is unreachable (nothing can find it) *and* unevictable (the cache +budget only sums tracked sessions). The work root is a tmpfs, so that is leaked RAM +until the next reboot — the restart that shipped the fix above would have stranded +1.56 GB. `reset_work_root()` now clears it at startup and logs what it reclaimed. +Session directories only; a stray file in the work root is left alone. diff --git a/proxy/ytstream_proxy.py b/proxy/ytstream_proxy.py index 31bcef8..7a6ed16 100644 --- a/proxy/ytstream_proxy.py +++ b/proxy/ytstream_proxy.py @@ -735,6 +735,27 @@ def make_handler(mgr, wait_timeout, first_byte_grace=FIRST_BYTE_GRACE): # -------------------------------------------------------------------------- +def reset_work_root(path): + """Clear leftover session directories at startup. Returns bytes reclaimed. + + The session map is in memory only, so anything already in the work root is + unreachable after a restart: it can never be served (no session to find) and + never be evicted (the cache accounting only sums tracked sessions). Since the + work root is a tmpfs, leaving it there leaks RAM until the next reboot -- a + restart with 1.56 GB cached stranded exactly that much. + """ + reclaimed = 0 + for name in os.listdir(path) if os.path.isdir(path) else []: + stale = os.path.join(path, name) + if not os.path.isdir(stale): + continue + out = os.path.join(stale, "out.mp4") + if os.path.exists(out): + reclaimed += os.path.getsize(out) + shutil.rmtree(stale, ignore_errors=True) + return reclaimed + + def main(): ap = argparse.ArgumentParser( description="just-in-time YouTube streaming proxy for Jellyfin") @@ -783,6 +804,11 @@ def main(): _access_log = args.access_log os.makedirs(args.work, exist_ok=True) + stranded = reset_work_root(args.work) + if stranded: + log(f"cleared {stranded / 2**30:.2f} GB of untracked cache from " + f"{args.work} left by a previous run") + mgr = Manager(args.work, args.max_pipelines, int(args.cache_gb * 2**30), args.no_fetch, args.growing, args.max_retries, args.max_starts, args.starts_window) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index d5647d9..9d3fee9 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -484,6 +484,32 @@ def test_concurrency_cap(tmp_path): assert len(manager.start_log) == 2 +def test_startup_clears_untracked_cache(tmp_path): + """The work root is a tmpfs and the session map is memory-only, so anything + left by a previous run is unreachable *and* unevictable -- it would leak RAM + until the next reboot.""" + work = tmp_path / "work" + (work / "abcdefghijk").mkdir(parents=True) + (work / "abcdefghijk" / "out.mp4").write_bytes(b"x" * 5000) + (work / "bcdefghijkl").mkdir() + (work / "bcdefghijkl" / "out.mp4").write_bytes(b"y" * 3000) + (work / "loose.txt").write_text("not a session") + + reclaimed = proxy.reset_work_root(str(work)) + + assert reclaimed == 8000 + assert not (work / "abcdefghijk").exists() + assert not (work / "bcdefghijkl").exists() + # A stray file is not a session directory and is left alone. + assert (work / "loose.txt").exists() + + +def test_startup_on_a_clean_work_root_is_a_no_op(tmp_path): + work = tmp_path / "empty" + work.mkdir() + assert proxy.reset_work_root(str(work)) == 0 + + def test_no_fetch_mode_refuses_everything(manager_factory): manager = manager_factory(no_fetch=True, max_starts=99) session, refusal = manager.get(vid(30))