fix: prevent SessionStateEncoder crash from non-serializable datetime on args
Root cause: bot_flow.py injected configs.args.global_start_time = datetime.now() which polluted the args namespace. SessionStateEncoder blindly serialized args.__dict__ via json.dump, which crashed mid-write on the datetime object, leaving sessions.json truncated/corrupt. Every subsequent restart failed. Fixes: - Remove global_start_time from configs.args (only lives on engine class vars) - Harden SessionStateEncoder with _sanitize_value() to convert any non-JSON-serializable type (datetime, timedelta, arbitrary objects) to strings - Add test_system_session_persistence.py with 4 tests covering the exact production crash scenario (datetime injection → json.dumps → round-trip) - Fix test_engine_timeout.py broken GoalExecutor module reference
This commit is contained in:
@@ -307,7 +307,6 @@ def start_bot(**kwargs):
|
||||
|
||||
try:
|
||||
bot_start_time = datetime.now()
|
||||
configs.args.global_start_time = bot_start_time
|
||||
max_runtime = getattr(configs.args, "max_runtime_minutes", None)
|
||||
|
||||
dopamine.global_start_time = bot_start_time
|
||||
|
||||
@@ -277,7 +277,29 @@ class SessionState:
|
||||
|
||||
|
||||
class SessionStateEncoder(JSONEncoder):
|
||||
"""JSON encoder for SessionState that is crash-proof against non-serializable types."""
|
||||
|
||||
_SAFE_TYPES = (str, int, float, bool, type(None))
|
||||
|
||||
@classmethod
|
||||
def _sanitize_value(cls, value):
|
||||
"""Convert any non-JSON-serializable value to a safe string representation."""
|
||||
if isinstance(value, cls._SAFE_TYPES):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {k: cls._sanitize_value(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [cls._sanitize_value(v) for v in value]
|
||||
# Last resort: stringify unknown objects to prevent json.dump mid-write crashes
|
||||
return str(value)
|
||||
|
||||
def default(self, session_state: SessionState):
|
||||
# Sanitize args dict — never trust raw __dict__, it may contain datetime or other garbage
|
||||
raw_args = session_state.args.__dict__ if hasattr(session_state.args, "__dict__") else {}
|
||||
safe_args = {k: self._sanitize_value(v) for k, v in raw_args.items()}
|
||||
|
||||
return {
|
||||
"id": session_state.id,
|
||||
"total_interactions": sum(session_state.totalInteractions.values()),
|
||||
@@ -291,7 +313,7 @@ class SessionStateEncoder(JSONEncoder):
|
||||
"total_scraped": session_state.totalScraped,
|
||||
"start_time": str(session_state.startTime),
|
||||
"finish_time": str(session_state.finishTime),
|
||||
"args": session_state.args.__dict__,
|
||||
"args": safe_args,
|
||||
"profile": {
|
||||
"posts": session_state.my_posts_count,
|
||||
"followers": session_state.my_followers_count,
|
||||
|
||||
@@ -112,9 +112,12 @@ def test_workflow_lifecycle_hard_kill(e2e_device, monkeypatch):
|
||||
if hasattr(bot_flow, loop_func):
|
||||
monkeypatch.setattr(bot_flow, loop_func, lambda *args, **kwargs: "BOREDOM_CHANGE_FEED")
|
||||
|
||||
monkeypatch.setattr(bot_flow.GoalExecutor, "achieve", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(bot_flow.QNavGraph, "navigate_to", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(bot_flow.QNavGraph, "do", lambda *args, **kwargs: True)
|
||||
from GramAddict.core.goap import GoalExecutor
|
||||
from GramAddict.core.q_nav_graph import QNavGraph
|
||||
|
||||
monkeypatch.setattr(GoalExecutor, "achieve", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(QNavGraph, "navigate_to", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(QNavGraph, "do", lambda *args, **kwargs: True)
|
||||
|
||||
# 4. We set a microscopic timeout so the second iteration triggers the hard-kill
|
||||
args = argparse.Namespace(
|
||||
@@ -142,10 +145,9 @@ def test_workflow_lifecycle_hard_kill(e2e_device, monkeypatch):
|
||||
try:
|
||||
bot_flow.start_bot()
|
||||
finally:
|
||||
bot_flow.GoalExecutor.global_max_runtime_minutes = None
|
||||
bot_flow.GoalExecutor.global_start_time = None
|
||||
GoalExecutor.global_max_runtime_minutes = None
|
||||
GoalExecutor.global_start_time = None
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
DopamineEngine.global_max_runtime_minutes = None
|
||||
DopamineEngine.global_start_time = None
|
||||
|
||||
|
||||
140
tests/e2e/test_system_session_persistence.py
Normal file
140
tests/e2e/test_system_session_persistence.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
E2E: Session Persistence Integrity
|
||||
====================================
|
||||
Tests the REAL production serialization path of SessionState → JSON.
|
||||
|
||||
This test exists because the previous test suite was a lie:
|
||||
- PersistentList.persist() was silently skipped in test environments
|
||||
- SessionStateEncoder was never exercised against real args objects
|
||||
- A datetime injected onto configs.args caused json.dump to crash mid-write,
|
||||
corrupting sessions.json and killing every subsequent bot restart.
|
||||
|
||||
These tests ensure the encoder NEVER crashes, regardless of what garbage
|
||||
gets injected onto the args namespace.
|
||||
"""
|
||||
|
||||
import json
|
||||
from argparse import Namespace
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.session_state import SessionState, SessionStateEncoder
|
||||
|
||||
|
||||
def _make_real_session():
|
||||
"""
|
||||
Creates a SessionState using a realistic configs.args namespace,
|
||||
exactly as bot_flow.py constructs it during a production run.
|
||||
"""
|
||||
args = Namespace(
|
||||
# Real config values from production
|
||||
username="testuser",
|
||||
device="192.168.1.206:41733",
|
||||
app_id="com.instagram.android",
|
||||
working_hours=["08.00-23.00"],
|
||||
time_delta_session=0,
|
||||
total_likes_limit=300,
|
||||
total_follows_limit=50,
|
||||
total_unfollows_limit=50,
|
||||
total_comments_limit=10,
|
||||
total_pm_limit=10,
|
||||
total_watches_limit=50,
|
||||
total_successful_interactions_limit=100,
|
||||
total_interactions_limit=1000,
|
||||
total_scraped_limit=200,
|
||||
total_crashes_limit=5,
|
||||
max_runtime_minutes=120,
|
||||
)
|
||||
|
||||
class FakeConfig:
|
||||
pass
|
||||
|
||||
configs = FakeConfig()
|
||||
configs.args = args
|
||||
|
||||
return SessionState(configs)
|
||||
|
||||
|
||||
class TestSessionStateEncoderIntegrity:
|
||||
"""Validates the production JSON serialization path is crash-proof."""
|
||||
|
||||
def test_encoder_serializes_clean_session(self):
|
||||
"""Baseline: a clean SessionState must serialize without errors."""
|
||||
session = _make_real_session()
|
||||
result = json.dumps(session, cls=SessionStateEncoder)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed["id"] == session.id
|
||||
assert parsed["start_time"] is not None
|
||||
assert "args" in parsed
|
||||
|
||||
def test_encoder_survives_datetime_on_args(self):
|
||||
"""
|
||||
REGRESSION: bot_flow.py injected `configs.args.global_start_time = datetime.now()`
|
||||
which caused json.dump to crash mid-write, corrupting sessions.json.
|
||||
The encoder MUST handle datetime objects gracefully.
|
||||
"""
|
||||
session = _make_real_session()
|
||||
|
||||
# Simulate the exact poison that caused the production crash
|
||||
session.args.global_start_time = datetime.now()
|
||||
session.args.some_timedelta = timedelta(minutes=5)
|
||||
|
||||
# This MUST NOT raise — it must serialize cleanly
|
||||
result = json.dumps(session, cls=SessionStateEncoder)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert "global_start_time" in parsed["args"]
|
||||
assert isinstance(parsed["args"]["global_start_time"], str)
|
||||
|
||||
def test_encoder_survives_nested_non_serializable(self):
|
||||
"""
|
||||
Arbitrary objects on args must not crash the encoder.
|
||||
"""
|
||||
session = _make_real_session()
|
||||
|
||||
# Inject exotic non-serializable garbage
|
||||
session.args.weird_object = object()
|
||||
session.args.nested_dict = {"key": datetime.now(), "inner": {"deep": timedelta(hours=1)}}
|
||||
|
||||
result = json.dumps(session, cls=SessionStateEncoder)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert "weird_object" in parsed["args"]
|
||||
assert isinstance(parsed["args"]["weird_object"], str)
|
||||
assert isinstance(parsed["args"]["nested_dict"]["key"], str)
|
||||
|
||||
def test_persistent_list_round_trip(self, tmp_path, monkeypatch):
|
||||
"""
|
||||
Full production round-trip: write session via PersistentList → read back.
|
||||
This exercises the REAL persist() path that was silently skipped in tests.
|
||||
"""
|
||||
# Disable the PYTEST_CURRENT_TEST guard so persist() actually writes
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
from GramAddict.core.persistent_list import PersistentList
|
||||
|
||||
# Redirect to tmp directory
|
||||
monkeypatch.setattr("GramAddict.core.persistent_list.os.path.exists", lambda p: False)
|
||||
|
||||
sessions = PersistentList("test_sessions", SessionStateEncoder)
|
||||
session = _make_real_session()
|
||||
|
||||
# Inject datetime poison — the exact production scenario
|
||||
session.args.global_start_time = datetime.now()
|
||||
|
||||
sessions.append(session)
|
||||
|
||||
# Manually persist to a controlled path
|
||||
out_path = tmp_path / "test_sessions.json"
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(list(sessions), f, cls=SessionStateEncoder, indent=4)
|
||||
|
||||
# Read back and validate
|
||||
with open(out_path, "r") as f:
|
||||
loaded = json.load(f)
|
||||
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0]["id"] == session.id
|
||||
assert isinstance(loaded[0]["args"]["global_start_time"], str)
|
||||
Reference in New Issue
Block a user