OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 2e768a0

The ingest queue survives more than one job finishing at once (0.16.1)
Found as a flaky test, but it was not a test problem. _set_job_state opened the queue file with "w", which truncates before anything is written. A reader arriving in that window got "Expecting value: line 1 column 1 (char 0)" -- and _load_job_queue catches everything and returns {} by design, precisely so a bad file cannot stop the addon booting. So a torn read never failed loudly. It reported an EMPTY queue, and the next write persisted that. Every job on record, gone, silently. The read-modify-write was also unguarded: two jobs finishing together both load the queue, both add their own key, and the second write drops the first. Two jobs finishing at once was already enough. A season finishing episode after episode, which 0.15.0 just made a normal thing to do, turns it from a race into a routine. So: a lock around the read-modify-write, and the write goes to a temp file and is renamed -- the same discipline the ingest itself has always used for a .part, applied to the file that records the ingests. Reproduced deliberately before fixing: a reader thread against a writer doing 150 updates logged 103 JSONDecodeErrors, and 24 concurrent writers left fewer than 24 entries. Both now pass, and the full suite is stable across five runs where it previously failed intermittently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2e768a08b63ddea6bd893d25b512844af47a3c1e
by plankatron <fleeky@gmail.com> · 2026-08-31T14:57:18.000Z

Changed files (2) +113 -10

addon.py +27 -10
……@@ -596,7 +596,7 @@ TV_GENRES = [
596596
597597MANIFEST = {
598598 "id": "org.tabertron.vsembed",
599 "version": "0.16.0",
599 "version": "0.16.1",
600600 "name": "Tabertron VsEmbed",
601601 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
602602 "types": ["movie", "series"],
……@@ -1201,6 +1201,13 @@ INGEST_QUEUE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
12011201 "ingest_queue.json")
12021202
12031203
1204# Guards the read-modify-write below. Without it two jobs finishing together
1205# both load the queue, both add their own key, and the second write drops the
1206# first -- and a season finishing episode after episode is exactly that, over
1207# and over.
1208_queue_guard = threading.Lock()
1209
1210
12041211def _load_job_queue() -> dict:
12051212 """Persisted job states. Never raises — this is a convenience, and a bad
12061213 file must not be a reason to refuse to boot."""
……@@ -1219,15 +1226,25 @@ def _set_job_state(key: str, state: str, args: Optional[tuple] = None) -> None:
12191226 """
12201227 _mp4_jobs[key] = state
12211228 try:
1222 queue = _load_job_queue()
1223 entry = queue.get(key) or {}
1224 entry["state"] = state
1225 if args is not None:
1226 entry["args"] = list(args)
1227 entry["ts"] = time.time()
1228 queue[key] = entry
1229 with open(INGEST_QUEUE_FILE, "w") as f:
1230 json.dump(queue, f)
1229 with _queue_guard:
1230 queue = _load_job_queue()
1231 entry = queue.get(key) or {}
1232 entry["state"] = state
1233 if args is not None:
1234 entry["args"] = list(args)
1235 entry["ts"] = time.time()
1236 queue[key] = entry
1237 # Written to a temp file and renamed, for the same reason the
1238 # ingest itself writes a .part. open(..., "w") TRUNCATES first, so a
1239 # reader arriving in that window got "Expecting value: line 1
1240 # column 1" -- and _load_job_queue catches everything and returns {}
1241 # by design, so a torn read did not fail loudly. It reported an
1242 # empty queue, and the next write persisted that. Every job on
1243 # record, gone.
1244 tmp = INGEST_QUEUE_FILE + ".tmp"
1245 with open(tmp, "w") as f:
1246 json.dump(queue, f)
1247 os.replace(tmp, INGEST_QUEUE_FILE)
12311248 except Exception as exc: # persistence is never load-bearing
12321249 log.warning("ingest queue save failed: %s", exc)
12331250
test_queue_atomicity.py +86 -0
……@@ -0,0 +1,86 @@
1"""The ingest queue survives more than one job finishing at once.
2
3Found by a flaky test, but it is not a test problem. _set_job_state truncated
4the queue file and then wrote it, so a concurrent reader could catch it empty --
5"Expecting value: line 1 column 1 (char 0)". _load_job_queue catches everything
6and returns {} by design, so a torn read does not fail loudly: it reports an
7empty queue, and the next write persists that. Every job on record, gone.
8
9Two jobs finishing together was enough to hit it. A season finishing episode
10after episode makes it routine, which is what made this worth fixing rather
11than retrying.
12
13Run: .venv/bin/python3 -m pytest test_queue_atomicity.py -q
14"""
15
16import json
17import os
18import threading
19
20import pytest
21
22import addon
23
24
25@pytest.fixture(autouse=True)
26def queue_file(tmp_path, monkeypatch):
27 monkeypatch.setattr(addon, "INGEST_QUEUE_FILE",
28 os.path.join(str(tmp_path), "queue.json"))
29 monkeypatch.setattr(addon, "_mp4_jobs", {})
30 yield
31
32
33def test_concurrent_writers_do_not_lose_each_others_entries():
34 """Read-modify-write without a lock: two threads both load the queue, both
35 add their own key, and the second write drops the first."""
36 def write(n):
37 addon._set_job_state(f"key{n}", "done", args=("series", f"tmdb:{n}", 1, 1, 720))
38
39 threads = [threading.Thread(target=write, args=(n,)) for n in range(24)]
40 for t in threads:
41 t.start()
42 for t in threads:
43 t.join(10)
44 assert len(addon._load_job_queue()) == 24
45
46
47def test_a_reader_never_sees_a_half_written_queue():
48 """The failure the flaky test kept hitting, reproduced deliberately."""
49 addon._set_job_state("seed", "done", args=("series", "tmdb:1", 1, 1, 720))
50 errors = []
51 stop = threading.Event()
52
53 def reader():
54 while not stop.is_set():
55 try:
56 with open(addon.INGEST_QUEUE_FILE) as f:
57 json.load(f)
58 except FileNotFoundError:
59 pass # the rename window, not a torn read
60 except Exception as exc:
61 errors.append(exc)
62
63 def writer():
64 for n in range(150):
65 addon._set_job_state(f"k{n}", "running", args=("series", "x", 1, n, 720))
66
67 r = threading.Thread(target=reader, daemon=True)
68 r.start()
69 w = threading.Thread(target=writer)
70 w.start()
71 w.join(30)
72 stop.set()
73 r.join(5)
74 assert errors == [], f"reader saw a partial file: {errors[:3]}"
75
76
77def test_the_queue_is_still_readable_afterwards():
78 addon._set_job_state("k", "done", args=("series", "tmdb:1", 1, 1, 720))
79 assert addon._load_job_queue()["k"]["args"] == ["series", "tmdb:1", 1, 1, 720]
80
81
82def test_no_temp_file_is_left_behind():
83 addon._set_job_state("k", "done", args=("series", "tmdb:1", 1, 1, 720))
84 leftovers = [f for f in os.listdir(os.path.dirname(addon.INGEST_QUEUE_FILE))
85 if f.endswith(".tmp")]
86 assert leftovers == []