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:
+117
-10
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user