fix: allow genuinely new playlist ingest without fresh Player evidence

The evidence gate on enqueue_download blocked every download, including a
URL that could only land in a slot store.reserve_download already proves is
empty and unguarded. After a Controller-only restart with the Player still
connected and idle, no fresh detailed status ever arrives, so this could
deadlock all new ingest indefinitely even though nothing unsafe would occur.

Narrow the gate to only require fresh evidence when the URL already occupies
a non-empty slot (refresh/reuse). Deletion, expiry, and cleanup/recovery
gating are unchanged.
This commit is contained in:
Media
2026-08-07 17:01:39 -05:00
parent 5e81403be3
commit 270ede8b77
4 changed files with 176 additions and 14 deletions
+117 -10
View File
@@ -88,6 +88,11 @@ class FakeDownloadManager:
self.expiry_calls = 0
self.stopped = False
self.delete_requests = []
self.enqueue_calls = []
def enqueue(self, url, submitter, note):
self.enqueue_calls.append((url, submitter, note))
return self.store.reserve_download(url, submitter, note)
def recover_pending_deletions(self):
self.recovered = True
@@ -492,22 +497,24 @@ class ControllerServiceTests(unittest.TestCase):
self.assertIn("mqtt_disconnected", health["degraded_reasons"])
self.assertIn("awaiting_player_evidence", health["degraded_reasons"])
def test_cleanup_and_downloads_wait_for_post_start_player_evidence(self):
def test_cleanup_waits_for_post_start_player_evidence_but_new_ingest_does_not(self):
self.start_service()
self.service._next_expiry = self.monotonic()
time.sleep(0.2)
self.assertEqual(self.downloads.expiry_calls, 0)
self.assertFalse(self.downloads.recovered)
with self.assertRaisesRegex(
ControllerUnavailableError,
"deferred until fresh Player evidence",
):
self.service.enqueue_download(
"https://example.test/deferred",
None,
None,
)
# A genuinely new playlist cannot mutate, delete, or replace any
# occupied slot, so it is not blocked by the missing evidence that
# still defers cleanup/expiry below.
result = self.service.enqueue_download(
"https://example.test/brand-new",
None,
None,
)
self.assertEqual(result["status"], "queued")
self.assertFalse(result["reused_slot"])
self.send_message(
self.config.topics.player_state,
@@ -540,6 +547,106 @@ class ControllerServiceTests(unittest.TestCase):
self.service.health()["degraded_reasons"],
)
def test_ingest_matching_an_occupied_slot_url_remains_gated_without_evidence(self):
self.start_service()
self.ready_slot(1)
with self.assertRaisesRegex(
ControllerUnavailableError,
"fresh Player evidence",
):
self.service.enqueue_download("https://example.test/1", None, None)
def test_controller_only_restart_with_idle_connected_player_allows_new_ingest(self):
"""Regression for the too-broad evidence gate deadlock.
A Controller-only restart with the Player still connected and idle
never produces fresh detailed evidence, because the Player has no
reason to reconnect or emit unsolicited status. New ingest into an
empty slot must still work; refresh/reuse of already-occupied media,
expiry, cleanup recovery, and manual deletion must all stay gated
until fresh evidence or an explicit administrative override arrives.
"""
self.start_service()
self.ready_slot(1)
self.send_message(
self.config.topics.player_status,
{"state": "idle", "message": "player online"},
qos=1,
)
self.wait_for(
lambda: "awaiting_player_evidence"
not in self.service.health()["degraded_reasons"],
"evidence did not clear before the simulated restart",
)
self.service.stop()
# Simulate a Controller-only restart against the same durable store:
# the Player process itself never restarted, stays connected and
# idle, and therefore never sends another fresh status.
self.events = queue.Queue()
restarted_store = ControllerStore(self.config.storage, now=self.wall)
restarted_mqtt = FakeMqttAdapter()
restarted_downloads = FakeDownloadManager(restarted_store)
restarted_service = ControllerService(
self.config,
store=restarted_store,
mqtt_adapter=restarted_mqtt,
event_queue=self.events,
download_manager=restarted_downloads,
monotonic=self.monotonic,
wall_clock=self.wall,
)
restarted_service.start()
self.addCleanup(
lambda: restarted_service.stop() if restarted_service.started else None
)
self.assertIn(
"awaiting_player_evidence",
restarted_service.health()["degraded_reasons"],
)
# A genuinely new playlist still ingests safely with no evidence.
result = restarted_service.enqueue_download(
"https://example.test/after-restart", None, None
)
self.assertEqual(result["status"], "queued")
self.assertFalse(result["reused_slot"])
self.assertNotEqual(result["slot"], 1)
# Refreshing the URL already occupying slot 1 remains gated.
with self.assertRaisesRegex(
ControllerUnavailableError,
"fresh Player evidence",
):
restarted_service.enqueue_download("https://example.test/1", None, None)
# Expiry and cleanup recovery remain deferred.
restarted_service._next_expiry = self.monotonic()
time.sleep(0.2)
self.assertEqual(restarted_downloads.expiry_calls, 0)
self.assertFalse(restarted_downloads.recovered)
# Manual deletion of occupied slot 1 still requires the explicit
# confirm_player_unknown override; it is not silently unlocked.
with self.assertRaisesRegex(
SlotProtectedError,
"confirm_player_unknown=true",
):
restarted_service.submit_control({"action": "delete_slot", "slot": 1})
# Fresh Player evidence still unlocks cleanup normally afterward.
self.send_message(
self.config.topics.player_status,
{"state": "idle", "message": "player online"},
qos=1,
)
self.wait_for(
lambda: restarted_downloads.recovered,
"fresh evidence did not unlock deferred recovery after restart",
)
def test_administrator_can_explicitly_unlock_cleanup_without_player_evidence(self):
self.start_service()
review = self.service.health()["cleanup_reconciliation"]
+14
View File
@@ -98,6 +98,20 @@ class FreshStoreTests(StoreTestCase):
self.assertTrue(store.mark_download_running("job-one", 1, 1))
self.assertEqual(store.get_slot(1).media_state, "downloading")
def test_find_occupied_slot_for_url_only_matches_non_empty_slots(self):
with self.open_store() as store:
url = "https://www.youtube.com/playlist?list=one"
self.assertIsNone(store.find_occupied_slot_for_url(url))
self.assertIsNone(store.find_occupied_slot_for_url(" "))
self.assertIsNone(store.find_occupied_slot_for_url(None))
store.reserve_download(url, None, None, job_id="job-one")
self.assertEqual(store.find_occupied_slot_for_url(url), 1)
self.assertEqual(store.find_occupied_slot_for_url(f" {url} "), 1)
self.assertIsNone(
store.find_occupied_slot_for_url("https://example.test/other")
)
def test_concurrent_requests_get_only_one_remaining_slot(self):
with self.open_store() as store:
for index in range(1, 10):