feat(streamer): purge après ACK + buffering SQLite persistant
- Nouveau module persistent_buffer.py (SQLite WAL, thread-safe)
- Purge automatique des captures locales après ACK 200 serveur
- Drain loop 15s, retry exponentiel, plafonds tentatives
- Enum ImageSendResult.{OK, FAILED, FILE_GONE} pour distinguer les cas
- FileNotFoundError n'est plus un faux succès (P0-E audit)
- 14 tests intégration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -184,8 +184,12 @@ class TestImagePayloadFormat:
|
||||
"""Le serveur distingue full/crop par '_crop' dans le shot_id."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
fake_img = tmp_path / "crop.png"
|
||||
fake_img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
# Dans le monde réel, full et crop sont deux fichiers distincts
|
||||
# (la purge après ACK supprime le premier avant que le second parte).
|
||||
fake_full = tmp_path / "full.png"
|
||||
fake_full.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
fake_crop = tmp_path / "crop.png"
|
||||
fake_crop.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
@@ -194,9 +198,9 @@ class TestImagePayloadFormat:
|
||||
streamer._server_available = True
|
||||
|
||||
# Full screenshot
|
||||
streamer._send_image(str(fake_img), "shot_0001_full")
|
||||
streamer._send_image(str(fake_full), "shot_0001_full")
|
||||
# Crop screenshot
|
||||
streamer._send_image(str(fake_img), "shot_0001_crop")
|
||||
streamer._send_image(str(fake_crop), "shot_0001_crop")
|
||||
|
||||
img_calls = [
|
||||
c for c in mock_req.post.call_args_list
|
||||
|
||||
378
tests/integration/test_streamer_buffer_and_purge.py
Normal file
378
tests/integration/test_streamer_buffer_and_purge.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Tests pour les fonctionnalités Partie A (purge après ACK) et Partie B
|
||||
(buffer persistant) du TraceStreamer — bloquants audit AI Act.
|
||||
|
||||
Aucun réseau : on mocke requests.post.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||
if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_png(path: Path, size: int = 100) -> Path:
|
||||
"""Crée un PNG minimal (header + padding) valide pour open()."""
|
||||
path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * size)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_buffer(tmp_path, monkeypatch):
|
||||
"""Isole le buffer persistant dans un tmp_path par test.
|
||||
|
||||
Le buffer est normalement partagé (BASE_DIR / "buffer"). On pointe
|
||||
vers un chemin jetable pour éviter la pollution croisée entre tests.
|
||||
"""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
buffer_dir = tmp_path / "buffer"
|
||||
monkeypatch.setattr(streamer_mod, "BUFFER_DIR", buffer_dir)
|
||||
return buffer_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partie A — Purge après ACK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPurgeAfterAck:
|
||||
"""Partie A : les screenshots locaux sont supprimés après HTTP 200."""
|
||||
|
||||
def test_image_purged_after_ack(self, tmp_path, isolated_buffer):
|
||||
"""Après HTTP 200, le fichier image local doit être supprimé."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
img_path = _make_png(tmp_path / "to_purge.png")
|
||||
assert img_path.exists()
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer = TraceStreamer("sess_purge_001")
|
||||
streamer._server_available = True
|
||||
result = streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
assert result is ImageSendResult.OK
|
||||
assert not img_path.exists(), "Fichier local doit être supprimé après ACK"
|
||||
|
||||
def test_image_not_purged_if_server_rejects(self, tmp_path, isolated_buffer):
|
||||
"""Si le serveur répond 500, le fichier local est conservé."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
img_path = _make_png(tmp_path / "keep_me.png")
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=False, status_code=500)
|
||||
streamer = TraceStreamer("sess_purge_002")
|
||||
streamer._server_available = True
|
||||
result = streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
assert result is ImageSendResult.FAILED
|
||||
assert img_path.exists(), "Fichier doit rester si le serveur rejette"
|
||||
|
||||
def test_purge_disabled_via_env(
|
||||
self, tmp_path, isolated_buffer, monkeypatch
|
||||
):
|
||||
"""RPA_PURGE_AFTER_ACK=0 désactive la purge."""
|
||||
# On patche PURGE_AFTER_ACK directement (lu au module load)
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
monkeypatch.setattr(streamer_mod, "PURGE_AFTER_ACK", False)
|
||||
|
||||
img_path = _make_png(tmp_path / "keep.png")
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer = streamer_mod.TraceStreamer("sess_purge_003")
|
||||
streamer._server_available = True
|
||||
streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
assert img_path.exists(), "Purge doit être désactivée"
|
||||
|
||||
def test_purge_does_not_crash_on_locked_file(
|
||||
self, tmp_path, isolated_buffer, monkeypatch
|
||||
):
|
||||
"""Si os.remove échoue (fichier verrouillé), pas de crash."""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
img_path = _make_png(tmp_path / "locked.png")
|
||||
|
||||
def _raise_permission(*_args, **_kwargs):
|
||||
raise PermissionError("Fichier verrouillé (simulé)")
|
||||
|
||||
monkeypatch.setattr(streamer_mod.os, "remove", _raise_permission)
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer = streamer_mod.TraceStreamer("sess_purge_004")
|
||||
streamer._server_available = True
|
||||
# Ne doit PAS lever
|
||||
result = streamer._send_image(str(img_path), "shot_test")
|
||||
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
assert result is ImageSendResult.OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partie B — Buffer persistant SQLite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistentBuffer:
|
||||
"""Partie B : persistance disque des events/images non envoyés."""
|
||||
|
||||
def test_priority_event_persisted_when_server_down(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Un event prioritaire est persisté si le serveur est indisponible."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_001")
|
||||
streamer._server_available = False
|
||||
|
||||
streamer.push_event({"type": "click", "pos": [100, 200]})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
counts = buf.counts()
|
||||
assert counts["events"] == 1, "Click doit être persisté"
|
||||
|
||||
def test_heartbeat_not_persisted_when_server_down(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Un heartbeat (non prioritaire) n'est PAS persisté."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_002")
|
||||
streamer._server_available = False
|
||||
|
||||
# La queue n'est pas pleine, donc le heartbeat va dans la queue RAM
|
||||
streamer.push_event({"type": "heartbeat", "image": "/tmp/h.png"})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
# Heartbeat reste dans la queue RAM (pas prioritaire → pas persisté)
|
||||
assert buf.counts()["events"] == 0
|
||||
|
||||
def test_image_persisted_when_server_down(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Une image est persistée si le serveur est indisponible."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
img = _make_png(tmp_path / "img.png")
|
||||
|
||||
streamer = TraceStreamer("sess_buf_003")
|
||||
streamer._server_available = False
|
||||
|
||||
streamer.push_image(str(img), "shot_001")
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["images"] == 1
|
||||
|
||||
def test_buffer_persists_when_queue_full(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Quand la queue RAM est pleine, un event prioritaire va en SQLite."""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
# Monkeypatch la taille max de queue pour forcer le débordement vite
|
||||
streamer = streamer_mod.TraceStreamer("sess_buf_004")
|
||||
streamer._server_available = True
|
||||
# Remplir artificiellement la queue
|
||||
import queue as _q
|
||||
|
||||
# Remplir jusqu'à être full
|
||||
while True:
|
||||
try:
|
||||
streamer.queue.put_nowait(("event", {"type": "noise"}))
|
||||
except _q.Full:
|
||||
break
|
||||
|
||||
# Maintenant queue pleine — un click doit aller en SQLite
|
||||
streamer.push_event({"type": "click", "pos": [1, 2]})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["events"] >= 1
|
||||
|
||||
def test_drain_replays_events_when_server_recovers(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Le drain rejoue les events persistés quand le serveur revient."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_005")
|
||||
# Persister un event pendant que le serveur est down
|
||||
streamer._server_available = False
|
||||
streamer.push_event({"type": "click", "pos": [50, 50]})
|
||||
|
||||
assert streamer._get_buffer().counts()["events"] == 1
|
||||
|
||||
# Serveur revient — on simule un drain manuel
|
||||
streamer._server_available = True
|
||||
with patch(
|
||||
"agent_v0.agent_v1.network.streamer.requests"
|
||||
) as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer._drain_buffer_once(streamer._get_buffer())
|
||||
|
||||
# L'event doit être envoyé ET supprimé du buffer
|
||||
event_calls = [
|
||||
c for c in mock_req.post.call_args_list if "/event" in str(c)
|
||||
]
|
||||
assert len(event_calls) == 1
|
||||
assert streamer._get_buffer().counts()["events"] == 0
|
||||
|
||||
def test_drain_increments_attempts_on_failure(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Si le drain échoue, attempts est incrémenté (pas de suppression)."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_006")
|
||||
streamer._server_available = False
|
||||
streamer.push_event({"type": "click"})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["events"] == 1
|
||||
|
||||
# Simule un envoi qui échoue (500)
|
||||
streamer._server_available = True
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=False, status_code=500)
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
# L'event reste dans le buffer avec attempts=1
|
||||
rows = buf.drain_events()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["attempts"] == 1
|
||||
|
||||
def test_event_abandoned_after_max_attempts(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Après MAX_ATTEMPTS, un event est abandonné (supprimé + log error)."""
|
||||
from agent_v0.agent_v1.network.persistent_buffer import (
|
||||
MAX_ATTEMPTS,
|
||||
PersistentBuffer,
|
||||
)
|
||||
|
||||
buf = PersistentBuffer(tmp_path / "buf")
|
||||
buf.add_event("sess_aband", {"type": "click"})
|
||||
|
||||
# Incrémenter attempts jusqu'au max
|
||||
rows = buf.drain_events()
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
buf.increment_attempts(rows[0]["id"], "event")
|
||||
|
||||
abandoned = buf.abandon_exceeded()
|
||||
assert abandoned == 1
|
||||
assert buf.counts()["events"] == 0
|
||||
|
||||
def test_buffer_survives_corrupted_db(self, tmp_path):
|
||||
"""Un fichier DB corrompu est renommé et un nouveau est créé."""
|
||||
from agent_v0.agent_v1.network.persistent_buffer import (
|
||||
PersistentBuffer,
|
||||
)
|
||||
|
||||
buffer_dir = tmp_path / "buf"
|
||||
buffer_dir.mkdir()
|
||||
# Créer un fichier "DB" corrompu
|
||||
db_path = buffer_dir / "pending_events.db"
|
||||
db_path.write_bytes(b"this is not a valid sqlite db file\x00\x01")
|
||||
|
||||
# Ne doit pas crasher
|
||||
buf = PersistentBuffer(buffer_dir)
|
||||
|
||||
# Le buffer doit être utilisable
|
||||
assert buf.add_event("sess_recover", {"type": "click"}) is True
|
||||
assert buf.counts()["events"] == 1
|
||||
|
||||
def test_drain_skips_image_with_missing_file(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Si le fichier image a disparu, on supprime l'entrée du buffer."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_buf_missing")
|
||||
streamer._server_available = False
|
||||
# Persister une image vers un chemin qui n'existe pas
|
||||
streamer.push_image("/tmp/does_not_exist_xyz.png", "shot_missing")
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["images"] == 1
|
||||
|
||||
# Drain : l'entrée doit être supprimée (fichier introuvable)
|
||||
streamer._server_available = True
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests") as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
assert buf.counts()["images"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scénarios complets (reprise, coupure réseau)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScenarios:
|
||||
"""Scénarios de bout en bout pour valider la reprise après incident."""
|
||||
|
||||
def test_scenario_server_offline_then_recover(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Scénario : serveur offline → events bufferisés → serveur revient
|
||||
→ drain automatique → buffer vide."""
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_scenario_001")
|
||||
|
||||
# 1) Serveur offline au démarrage
|
||||
streamer._server_available = False
|
||||
|
||||
# 2) L'utilisateur clique 5 fois
|
||||
for i in range(5):
|
||||
streamer.push_event({"type": "click", "pos": [i, i]})
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["events"] == 5, "5 clicks doivent être persistés"
|
||||
|
||||
# 3) Le serveur revient
|
||||
streamer._server_available = True
|
||||
|
||||
# 4) Drain manuel (équivalent boucle)
|
||||
with patch(
|
||||
"agent_v0.agent_v1.network.streamer.requests"
|
||||
) as mock_req:
|
||||
mock_req.post.return_value = MagicMock(ok=True)
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
# 5) Tous les events ont été envoyés dans l'ordre
|
||||
event_calls = [
|
||||
c for c in mock_req.post.call_args_list if "/event" in str(c)
|
||||
]
|
||||
assert len(event_calls) == 5
|
||||
# Vérifier l'ordre (positions croissantes)
|
||||
positions = [
|
||||
c[1]["json"]["event"]["pos"][0] for c in event_calls
|
||||
]
|
||||
assert positions == [0, 1, 2, 3, 4]
|
||||
|
||||
assert buf.counts()["events"] == 0
|
||||
214
tests/integration/test_streamer_file_gone_p0e.py
Normal file
214
tests/integration/test_streamer_file_gone_p0e.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Tests du Fix P0-E : FileNotFoundError dans _send_image n'est pas un succès.
|
||||
|
||||
Avant : un fichier image disparu retournait `True` (succès logique) — donc
|
||||
le buffer SQLite supprimait l'entrée alors que le serveur n'avait jamais
|
||||
reçu l'image. Perte silencieuse, contradiction avec la sémantique
|
||||
"succès = HTTP 200".
|
||||
|
||||
Après : retourne `ImageSendResult.FILE_GONE` distinct de `OK`. Le drain
|
||||
du buffer supprime l'entrée mais avec un log ERROR explicite (pas de retry,
|
||||
pas de confusion avec un succès réseau).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_buffer(tmp_path, monkeypatch):
|
||||
"""Isole le buffer persistant dans un tmp_path par test."""
|
||||
from agent_v0.agent_v1.network import streamer as streamer_mod
|
||||
|
||||
buffer_dir = tmp_path / "buffer"
|
||||
monkeypatch.setattr(streamer_mod, "BUFFER_DIR", buffer_dir)
|
||||
return buffer_dir
|
||||
|
||||
|
||||
class TestImageSendResultEnum:
|
||||
"""Vérifier l'existence et le contrat de l'enum ImageSendResult."""
|
||||
|
||||
def test_enum_has_three_values(self):
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
|
||||
assert ImageSendResult.OK.value == "ok"
|
||||
assert ImageSendResult.FAILED.value == "failed"
|
||||
assert ImageSendResult.FILE_GONE.value == "file_gone"
|
||||
|
||||
def test_enum_values_distinct(self):
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
|
||||
assert ImageSendResult.OK is not ImageSendResult.FAILED
|
||||
assert ImageSendResult.OK is not ImageSendResult.FILE_GONE
|
||||
assert ImageSendResult.FAILED is not ImageSendResult.FILE_GONE
|
||||
|
||||
|
||||
class TestSendImageReturnsFileGone:
|
||||
"""_send_image doit retourner FILE_GONE si le fichier n'existe pas."""
|
||||
|
||||
def test_missing_file_returns_file_gone(self, tmp_path, isolated_buffer):
|
||||
"""Fichier inexistant → FILE_GONE (pas OK, pas FAILED)."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_001")
|
||||
streamer._server_available = True
|
||||
|
||||
# On NE crée pas le fichier
|
||||
missing_path = str(tmp_path / "i_do_not_exist.png")
|
||||
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests"):
|
||||
result = streamer._send_image(missing_path, "shot_lost")
|
||||
|
||||
assert result is ImageSendResult.FILE_GONE, (
|
||||
f"Attendu FILE_GONE, reçu {result}"
|
||||
)
|
||||
|
||||
def test_file_gone_is_not_truthy_for_legacy_callers(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Un caller legacy qui fait `if result:` ne doit PAS interpréter
|
||||
FILE_GONE comme un succès."""
|
||||
from agent_v0.agent_v1.network.streamer import ImageSendResult
|
||||
|
||||
# FILE_GONE est un membre d'enum non vide → en Python il est truthy
|
||||
# par défaut. C'est pour ça qu'on ne peut PAS se contenter du test
|
||||
# bool(result) pour distinguer succès/échec : il faut comparer is OK.
|
||||
# Ce test documente le contrat : les callers DOIVENT comparer is OK.
|
||||
result = ImageSendResult.FILE_GONE
|
||||
assert result is not ImageSendResult.OK
|
||||
assert result is not True
|
||||
|
||||
|
||||
class TestDrainHandlesFileGone:
|
||||
"""Le drain du buffer doit supprimer l'entrée FILE_GONE avec log ERROR."""
|
||||
|
||||
def test_drain_removes_buffer_entry_for_missing_file(
|
||||
self, tmp_path, isolated_buffer, caplog
|
||||
):
|
||||
"""Si le fichier disparait entre la persistance et le drain :
|
||||
- L'entrée est supprimée du buffer (pas de retry infini)
|
||||
- Un log ERROR signale la perte
|
||||
"""
|
||||
import logging
|
||||
|
||||
from agent_v0.agent_v1.network.streamer import TraceStreamer
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_drain")
|
||||
streamer._server_available = False
|
||||
|
||||
# Persister une image vers un chemin inexistant
|
||||
ghost_path = str(tmp_path / "ghost.png")
|
||||
streamer.push_image(ghost_path, "shot_ghost")
|
||||
|
||||
buf = streamer._get_buffer()
|
||||
assert buf.counts()["images"] == 1
|
||||
|
||||
# Drain avec serveur dispo : doit détecter l'absence et abandonner
|
||||
streamer._server_available = True
|
||||
with caplog.at_level(logging.ERROR, logger="agent_v0.agent_v1.network.streamer"):
|
||||
with patch("agent_v0.agent_v1.network.streamer.requests"):
|
||||
streamer._drain_buffer_once(buf)
|
||||
|
||||
assert buf.counts()["images"] == 0, (
|
||||
"L'entrée doit être supprimée (retry voué à échouer)"
|
||||
)
|
||||
|
||||
# Vérifier qu'un log ERROR a été émis (pas seulement un warning)
|
||||
error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(error_logs) >= 1, (
|
||||
"Un log ERROR doit signaler que le serveur n'a rien reçu"
|
||||
)
|
||||
assert any(
|
||||
"abandonnée" in r.getMessage() or "introuvable" in r.getMessage()
|
||||
or "abandonnée" in r.getMessage().lower()
|
||||
for r in error_logs
|
||||
)
|
||||
|
||||
def test_send_image_file_disappears_during_send(
|
||||
self, tmp_path, isolated_buffer, caplog
|
||||
):
|
||||
"""Cas tordu : le fichier existe au moment de drain_images mais
|
||||
disparait pendant _send_image (race condition disque).
|
||||
|
||||
On simule en patchant _compress_image_to_jpeg pour lever
|
||||
FileNotFoundError.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
# Fichier existant initialement
|
||||
img_path = tmp_path / "race.png"
|
||||
img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_race")
|
||||
streamer._server_available = True
|
||||
|
||||
# Forcer FileNotFoundError dans le pipeline d'envoi (compression
|
||||
# tente d'ouvrir le fichier — qui aura "disparu" entre temps).
|
||||
def _gone(_path):
|
||||
raise FileNotFoundError(f"race condition: {_path}")
|
||||
|
||||
with patch.object(streamer, "_compress_image_to_jpeg", _gone), \
|
||||
patch("agent_v0.agent_v1.network.streamer.requests"), \
|
||||
caplog.at_level(logging.ERROR, logger="agent_v0.agent_v1.network.streamer"):
|
||||
result = streamer._send_image(str(img_path), "shot_race")
|
||||
|
||||
assert result is ImageSendResult.FILE_GONE, (
|
||||
"FileNotFoundError pendant la compression → FILE_GONE"
|
||||
)
|
||||
# Log ERROR (pas debug comme avant)
|
||||
error_logs = [r for r in caplog.records if r.levelno >= logging.ERROR]
|
||||
assert len(error_logs) >= 1
|
||||
|
||||
|
||||
class TestStreamLoopHandlesFileGone:
|
||||
"""La boucle d'envoi ne doit PAS persister une entrée FILE_GONE."""
|
||||
|
||||
def test_file_gone_not_persisted_to_buffer(
|
||||
self, tmp_path, isolated_buffer
|
||||
):
|
||||
"""Quand _send_image retourne FILE_GONE, on ne réécrit pas dans
|
||||
le buffer (sinon boucle infinie : add → drain → file_gone → add…)."""
|
||||
from agent_v0.agent_v1.network.streamer import (
|
||||
ImageSendResult,
|
||||
TraceStreamer,
|
||||
)
|
||||
|
||||
streamer = TraceStreamer("sess_p0e_loop")
|
||||
streamer._server_available = True
|
||||
|
||||
# Mock _send_with_retry pour retourner FILE_GONE directement
|
||||
with patch.object(
|
||||
streamer, "_send_with_retry", return_value=ImageSendResult.FILE_GONE
|
||||
):
|
||||
# Mettre une image dans la queue
|
||||
streamer.queue.put(("image", ("/tmp/whatever.png", "shot_x")))
|
||||
# Lancer une seule itération de la boucle (en simulant)
|
||||
try:
|
||||
item_type, data = streamer.queue.get(timeout=0.1)
|
||||
# Reproduire la logique du _stream_loop
|
||||
result = streamer._send_with_retry(
|
||||
streamer._send_image, *data
|
||||
)
|
||||
assert result is ImageSendResult.FILE_GONE
|
||||
# Le caller (stream_loop) doit identifier FILE_GONE comme
|
||||
# "ne pas persister" → on vérifie que le buffer reste vide
|
||||
buf = streamer._get_buffer()
|
||||
# Avant le fix : l'item aurait été persisté car "consecutive_failures += 1"
|
||||
# et "if priority_item: persist()". Avec le fix, on saute.
|
||||
assert buf.counts()["images"] == 0
|
||||
finally:
|
||||
streamer.queue.task_done()
|
||||
Reference in New Issue
Block a user