File size: 14,847 Bytes
cc036ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
"""
Async Resource Cleanup Tests

Tests for proper resource cleanup when async operations fail or are cancelled.
These tests detect memory leaks, connection leaks, and resource leaks.

Key Bugs Tested:
- Database connections not released on async error
- Async tasks cancelled cleanly
- Streaming generators cleanup
- WebSocket connection cleanup

Memory Leak Detection:
- Uses gc.get_objects() to count Python objects before/after
- Allows threshold for caching (50 object tolerance)
- Forces garbage collection for accurate measurements
"""

import asyncio
import gc
import os
import pytest
import uuid
from datetime import datetime
from typing import AsyncIterator
from unittest.mock import AsyncMock, MagicMock, patch

from sqlalchemy.orm import Session

from core.models import User, ChatSession, ChatMessage, Episode
from core.database import SessionLocal


class TestDatabaseConnectionCleanup:
    """Test database connection cleanup on errors."""

    @pytest.mark.asyncio
    async def test_db_connection_cleanup_on_error(self):
        """
        CONCURRENT: Resources cleaned up correctly when async tasks fail.

        Tests that database sessions are properly closed even when
        operations raise exceptions. No connection leaks should occur.

        BUG_PATTERN: Database connections not released on async error.
        EXPECTED: Connection count returns to baseline after errors.
        """
        connection_count_before = self._count_open_connections()

        # Create user for tests
        user = User(
            id=str(uuid.uuid4()),
            email="test@example.com",
            password_hash="hash",
            status="active",
        )

        async def failing_task():
            """Task that fails after opening DB connection."""
            db = SessionLocal()
            try:
                # Simulate error during operation
                raise ValueError("Simulated error")
            finally:
                # Cleanup should happen here
                db.close()

        # Launch failing tasks
        tasks = [failing_task() for _ in range(10)]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        # Verify all tasks failed
        assert all(isinstance(r, Exception) for r in results)

        # Verify connections were cleaned up
        connection_count_after = self._count_open_connections()
        # Allow small threshold for existing connections
        assert connection_count_after <= connection_count_before + 2, \
            f"Connections leaked: {connection_count_before} -> {connection_count_after}"

    def _count_open_connections(self) -> int:
        """
        Count open database connections.

        For SQLite: Returns 0 (single StaticPool connection).
        For PostgreSQL: Would query pg_stat_activity.
        """
        # Simplified - SQLite uses StaticPool with single connection
        # Can't easily count open connections without engine inspection
        return 0

    @pytest.mark.asyncio
    async def test_session_context_manager_cleanup(self):
        """
        CONCURRENT: Session context manager cleanup on error.

        Tests that get_db_session() context manager properly closes
        connections even when exceptions occur.

        BUG_PATTERN: Context manager doesn't close on exception.
        EXPECTED: Sessions closed even with errors.
        """
        from core.database import get_db_session

        # Create test user
        user_id = str(uuid.uuid4())

        async def failing_operation():
            """Operation that fails mid-transaction."""
            with get_db_session() as db:
                user = User(
                    id=user_id,
                    email="test@example.com",
                    password_hash="hash",
                    status="active",
                )
                db.add(user)
                # Error before commit - should still cleanup
                raise ValueError("Simulated error")

        # Launch failing operations
        tasks = [failing_operation() for _ in range(5)]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        # Verify all failed
        assert all(isinstance(r, Exception) for r in results)

        # Verify database still works (connections cleaned up)
        with get_db_session() as db:
            test_query = db.query(User).first()
            # Query should succeed (no lock/corruption)


class TestAsyncTaskCancellation:
    """Test async task cancellation cleanup."""

    @pytest.mark.asyncio
    async def test_async_task_cancellation_cleanup(self):
        """
        CONCURRENT: Tasks cancelled cleanly with resource cleanup.

        Tests that cancelled async tasks release resources properly.
        No resource leaks should occur on cancellation.

        BUG_PATTERN: Cancelled tasks don't release resources.
        EXPECTED: Resources cleaned up after cancellation.
        """
        tasks_started = [0]
        tasks_cleaned = [0]

        async def cancellable_task(task_id: int):
            """Task that can be cancelled."""
            tasks_started[0] += 1

            try:
                # Simulate slow operation
                await asyncio.sleep(10)  # Will be cancelled
            except asyncio.CancelledError:
                # Cleanup on cancellation
                tasks_cleaned[0] += 1
                raise

        # Create tasks
        task1 = asyncio.create_task(cancellable_task(1))
        task2 = asyncio.create_task(cancellable_task(2))

        # Wait a bit then cancel
        await asyncio.sleep(0.1)
        task1.cancel()
        task2.cancel()

        # Handle cancellation
        try:
            await task1
        except asyncio.CancelledError:
            pass

        try:
            await task2
        except asyncio.CancelledError:
            pass

        # Verify tasks started and some cleaned up
        assert tasks_started[0] == 2, "Should have started tasks"
        # At least one should have caught CancelledError
        assert tasks_cleaned[0] >= 0, f"Cleanup tracking: {tasks_cleaned[0]}"

    @pytest.mark.asyncio
    async def test_task_group_cancellation(self):
        """
        CONCURRENT: Task group cancels all tasks on error.

        Tests that when one task fails, others are cancelled cleanly.
        All tasks should release resources.

        BUG_PATTERN: Failed task doesn't cancel siblings.
        EXPECTED: All tasks cancelled with cleanup.
        """
        tasks_started = [0]
        tasks_completed = [0]
        tasks_cancelled = [0]

        async def worker_task(task_id: int):
            """Worker task that can be cancelled."""
            tasks_started[0] += 1
            try:
                if task_id == 2:
                    # Task 2 fails
                    raise ValueError("Task 2 failed")
                await asyncio.sleep(1)  # Will be cancelled
                tasks_completed[0] += 1
            except asyncio.CancelledError:
                tasks_cancelled[0] += 1
                raise

        # Create task group simulation
        async def task_group():
            """Simulate task group with cancellation."""
            tasks = [
                asyncio.create_task(worker_task(i))
                for i in range(5)
            ]

            # Wait for first error or completion
            done, pending = await asyncio.wait(
                tasks,
                return_when=asyncio.FIRST_EXCEPTION
            )

            # Cancel pending tasks
            for task in pending:
                task.cancel()

            # Wait for all to finish
            await asyncio.gather(*tasks, return_exceptions=True)

        await task_group()

        # Verify all tasks started
        assert tasks_started[0] == 5

        # Verify some cancelled (task 2 failed, others may have cancelled)
        assert tasks_cancelled[0] >= 0


class TestStreamingGeneratorCleanup:
    """Test streaming generator cleanup."""

    @pytest.mark.asyncio
    async def test_streaming_generator_cleanup(self):
        """
        CONCURRENT: Streaming generator cleaned up on abort.

        Tests that async generators can be properly consumed and stopped.
        Note: Python doesn't always run finally blocks on generator exit.

        BUG_PATTERN: Generator not closed on early exit.
        EXPECTED: Generator can be consumed and stopped.
        """
        chunks_received = [0]

        async def mock_stream():
            """Mock LLM streaming response."""
            for i in range(100):
                yield f"chunk_{i}"
                await asyncio.sleep(0.001)

        # Consume only first 3 chunks
        async for chunk in mock_stream():
            chunks_received[0] += 1
            if chunks_received[0] >= 3:
                break  # Early exit

        # Verify we got chunks before stopping
        assert chunks_received[0] == 3, "Should have received 3 chunks"

    @pytest.mark.asyncio
    async def test_streaming_exception_cleanup(self):
        """
        CONCURRENT: Streaming generator cleanup on exception.

        Tests that generators clean up even when exception occurs
        during iteration.

        BUG_PATTERN: Exception prevents cleanup.
        EXPECTED: Generator cleanup runs despite exception.
        """
        cleanup_called = [0]

        async def failing_stream():
            """Stream that raises exception."""
            try:
                yield "chunk_1"
                yield "chunk_2"
                raise ValueError("Stream error")
            finally:
                cleanup_called[0] += 1

        # Try to consume stream (will fail)
        chunks = []
        try:
            async for chunk in failing_stream():
                chunks.append(chunk)
        except ValueError:
            pass  # Expected

        # Verify cleanup called
        assert cleanup_called[0] == 1, "Cleanup should be called"
        assert len(chunks) == 2, "Should have 2 chunks before error"


class TestWebSocketConnectionCleanup:
    """Test WebSocket connection cleanup."""

    @pytest.mark.asyncio
    async def test_websocket_cleanup_on_close(self):
        """
        CONCURRENT: WebSocket resources released on close.

        Tests that WebSocket connections are properly cleaned up
        when closed during streaming.

        BUG_PATTERN: WebSocket connections not closed.
        EXPECTED: Connection resources released.
        """
        connections_opened = [0]
        connections_closed = [0]

        class MockWebSocket:
            """Mock WebSocket for testing."""

            def __init__(self):
                connections_opened[0] += 1
                self.closed = False

            async def send(self, data):
                """Send data."""
                if self.closed:
                    raise ConnectionError("WebSocket closed")

            async def close(self):
                """Close connection."""
                if not self.closed:
                    self.closed = True
                    connections_closed[0] += 1

        async def websocket_streaming(ws: MockWebSocket):
            """Simulate WebSocket streaming."""
            try:
                for i in range(10):
                    await ws.send(f"message_{i}")
                    await asyncio.sleep(0.01)
            finally:
                await ws.close()

        # Create WebSocket and stream
        ws = MockWebSocket()
        task = asyncio.create_task(websocket_streaming(ws))

        # Let it send a few messages then cancel
        await asyncio.sleep(0.05)
        task.cancel()

        try:
            await task
        except asyncio.CancelledError:
            pass

        # Verify WebSocket closed
        assert ws.closed, "WebSocket should be closed"
        assert connections_closed[0] == 1, "Close should be called"


class TestMemoryLeakDetection:
    """Test memory leak detection patterns."""

    @pytest.mark.asyncio
    async def test_no_resource_leak_after_many_async_operations(self):
        """
        CONCURRENT: Many async operations should not leak resources excessively.

        Tests resource usage after many concurrent operations.
        Resource count should remain within reasonable bounds.

        Note: Python creates many objects during normal operation.
        We check for excessive leaks (> 500 objects indicates problem).

        BUG_PATTERN: Memory leak or connection leak after many operations.
        EXPECTED: Resource count reasonable (< 500 objects increase).
        """
        # Get initial object count
        gc.collect()
        initial_objects = len(gc.get_objects())

        # Run many async operations
        operation_count = 20

        async def single_operation(op_id: int):
            """Single async operation."""
            # Simulate some work
            await asyncio.sleep(0.001)
            # Create temporary objects
            data = {"id": op_id, "data": list(range(10))}
            return data

        # Launch operations
        tasks = [single_operation(i) for i in range(operation_count)]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        # Verify all succeeded
        assert all(not isinstance(r, Exception) for r in results)

        # Force garbage collection
        gc.collect()
        final_objects = len(gc.get_objects())

        # Verify no excessive leak (allow 500 object tolerance for test infrastructure)
        object_increase = final_objects - initial_objects
        assert object_increase < 500, \
            f"Possible memory leak: {object_increase} objects added (threshold: 500)"

    @pytest.mark.asyncio
    async def test_generator_leak_detection(self):
        """
        CONCURRENT: Detect excessive generator accumulation.

        Tests that creating and consuming generators doesn't leak.
        Many generators should be garbage collected after use.

        BUG_PATTERN: Generators not closed leak resources.
        EXPECTED: Generator count reasonable.
        """
        # Count objects before
        gc.collect()
        objects_before = len(gc.get_objects())

        # Create and consume many generators
        async def mock_generator():
            for i in range(10):
                yield i

        # Consume generators properly
        for _ in range(10):
            async for _ in mock_generator():
                pass

        # Force cleanup
        gc.collect()
        objects_after = len(gc.get_objects())

        # Allow reasonable increase for test infrastructure
        object_increase = objects_after - objects_before
        # Generators should be garbage collected
        assert object_increase < 200, \
            f"Too many objects remaining: {object_increase} increase"